dsh-composer-plus 0.0.0-stage → 0.6.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 furkancak1r
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # dsh-composer-plus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Small composer helpers for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (web / desktop UI). The client half adds the UI; a small Host half serves read-only git changes and saves Auto Review grants. No external network calls.
4
+
5
+ - **Input history** – in an empty composer, `↑` / `↓` walk your previously sent messages, like a terminal. An open `/` menu or an edited draft keeps the normal keys.
6
+ - **Plan mode toggle** – while the chat is in plan mode, `Shift+Tab` leaves it (same as clicking the plan chip). Otherwise it adds `/plan ` to the start of the draft, even when it already has text; press it again to remove that leading `/plan`. The rest of the draft, file references and attachments stay intact, and the caret ends up at the end. It never sends. An open `/` or `@` menu keeps `Shift+Tab` for closing itself.
7
+ - **One-click Continue** – when a turn has stopped and the composer is empty, the Send button becomes **▶**; one click sends `continue`. Hidden on a blank chat, while running, in subagent views, or once you type or attach something.
8
+ - **Copy deeplink** – right-click a session in the sidebar (or use its `…` menu) and pick **Copy deeplink** (label follows dsh's language: English or Chinese). You get a link like `dsh-session:InNlc3Npb24t…`; paste it into another chat and dsh attaches that session as read-only context.
9
+ - **Allow for this chat (Auto Review)** – when Auto Review denies a call and asks you, the approval card gets an **Allow for this chat** button next to Reject / Allow once. It allows this call and every later Auto Review ask for the **same exact tool in the same chat**, with any arguments (e.g. any read-only SQL query on one database tool). Other tools, other chats and non-Auto-Review approvals still ask. A header chip (“N tool(s) allowed · Revoke”) lists the grants and revokes them in one click. Grants are saved by the plugin's Host half (`$DSH_HOME/storages/dsh-composer-plus/grants.json`, user-only permissions), so they survive a restart until you revoke them. If the tool can write, the grant covers any write too.
10
+ - **Changes panel** – a Codex-style **Changes** page in the right sidebar (open it from the sidebar's `+` guide). Pick **Uncommitted**, **Unstaged**, **Staged** or **Branch** (your branch vs. its upstream, from their merge base) to list changed files with `+/-` counts and expand any file for its diff; **Last Turn** opens dsh's own review of the latest turn. It refreshes while visible. Git runs read-only on the Host in the chat's own folder.
11
+ - **Background notifications** – while the dsh window is not focused, you get an OS notification titled with the chat name when a turn finishes (with the reply's first line), or when a chat waits for an approval (names the tool), a question (shows it) or a plan review. The Dock/taskbar badge counts chats waiting for you. Clicking it brings dsh to the front and opens that chat. Notifications stay in Notification Center until clicked; a newer one for the same chat replaces the older. Subagent chats don't notify on their own (the parent chat does). Text follows dsh's language (English or Chinese).
12
+ - **Drag-and-drop queue reordering** – queued messages get a grip (⠿) at the left. Drag a row to a new position (drop line shown, `Esc` cancels), or focus the grip and press `↑` / `↓`.
13
+
14
+ Tested on dsh `0.2.0-rc.2` (desktop and web) and `0.2.1-alpha.2` (web). CI runs the tests on every push.
15
+
16
+ ## Install
17
+
18
+ **Desktop app:** Settings → Plugins → **Add plugin**, enter `dsh-composer-plus` (npm) or paste `https://github.com/furkancak1r/dsh-composer-plus`, then **Enable now** (or restart dsh). The desktop profile is managed by the app, so the CLI cannot install into it.
19
+
20
+ **Web / CLI profiles:**
21
+
22
+ ```sh
23
+ dsh plugin --profile web add dsh-composer-plus
24
+ ```
25
+
26
+ This adds the dependency and the profile bundle. Restart dsh.
27
+
28
+ pnpm in dsh skips versions younger than 24 hours by default (`minimum-release-age`). Right after a release, name the version explicitly, e.g. `dsh-composer-plus@0.6.1`.
29
+
30
+ ## How it works / limits
31
+
32
+ - dsh's `session.updateQueue` has no `move` action, so a reorder rotates the affected rows' text with `edit` calls (tail → head). If the agent claims a row mid-move, the applied edits are reverted and a notice is shown; nothing is lost or duplicated.
33
+ - Rows with images/files can't be moved or crossed (queue `edit` is text-only).
34
+ - A deeplink uses dsh's `dsh-session:` reference format. It references a session inside chats; clicking it does not open the session (dsh's `dsh://` handler only focuses the app today).
35
+ - Session rows have no context-menu slot, so right-click finds the row's own `…` button in the DOM. If dsh changes that markup, right-click shows the normal menu again; the `…` menu item keeps working.
36
+ - The Send button and queue rows are not slots, so ▶ and the grips are portaled into the existing DOM. If a dsh update changes that markup, they simply stop appearing; nothing else breaks.
37
+ - “Allow for this chat” answers the existing approval request with dsh's own `allowed-once` outcome; Auto Review still runs on every call, and review failures are never auto-allowed. The button is portaled into the approval card's button row (no slot exists); if dsh changes that markup, the button disappears and Allow once / Reject keep working.
38
+ - Notifications use the web `Notification` API, which the desktop app allows; macOS asks for permission on the first one. Banners disappear after a few seconds by default; set the app's style to **Alerts** in System Settings → Notifications to keep them on screen. A notification sent before a restart only brings dsh to the front when clicked.
39
+ - A native `move` action is requested in [#773](https://github.com/deepseek-ai/deepseek-harness/discussions/773); a built-in Continue state in [#9269](https://github.com/deepseek-ai/deepseek-harness/discussions/9269).
40
+
41
+ ## Tests
42
+
43
+ ```sh
44
+ npm test
45
+ ```
46
+
47
+ CI runs the same tests on every push.
48
+
49
+ Runs the real `client.js` against a fake React/DOM (/plan toggle, deeplink format and right-click, Continue, queue drag, Auto Review grants including persistence across a restart, notification bodies and Dock badge) and the real `index.js` against a temporary git repository (every Changes mode, rename parsing, untracked files, refusal of paths git did not list, grants file).
50
+
51
+ `node test/browser-history.mjs '<dsh web URL with ?token=…>'` checks ↑/↓ history in a real browser against a throwaway `dsh web` (it sends test messages, so use a separate `DSH_HOME`). The `/plan` toggle (empty, multi-line, references, attachments, caret position), Continue and drag-and-drop were also checked by hand-scripted browser runs against a slow fake model.
52
+
53
+ MIT License. Unofficial community plugin.
package/client.js ADDED
@@ -0,0 +1,852 @@
1
+ /**
2
+ * dsh-composer-plus: composer history (ArrowUp/ArrowDown), Shift+Tab `/plan` toggle, a one-click
3
+ * Continue button, drag-and-drop reordering of queued messages, a session "Copy deeplink" menu item and an
4
+ * Auto Review "Allow for this chat" grant per exact tool (saved until revoked), and OS notifications while dsh is in the
5
+ * background (turn finished, approval/question/plan review waiting), and a Codex-style "Changes" page in the right
6
+ * sidebar. The Host half (index.js) serves read-only git changes and persists Auto Review grants.
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: 'dsh-composer-plus',
10
+ factory(require) {
11
+ const React = require('react');
12
+
13
+ /** Human-sent message texts in transcript order, newest last, repeats collapsed. */
14
+ function sentTexts(order, nodes) {
15
+ const list = [];
16
+ for (const key of order) {
17
+ const node = nodes.get(key);
18
+ if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue;
19
+ const { content, source } = node.data;
20
+ // Skip parent-agent, schedule and other non-human inputs.
21
+ if ((source !== undefined && source.kind !== 'user') || !Array.isArray(content)) continue;
22
+ const text = content
23
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
24
+ .map((block) => block.text)
25
+ .join('')
26
+ .trim();
27
+ if (text !== '' && list[list.length - 1] !== text) list.push(text);
28
+ }
29
+ return list;
30
+ }
31
+
32
+ function ComposerHistory({ useChat, useInput, inputActions }) {
33
+ // Hooks run only here, during render; the key handler reads their latest values from `live`.
34
+ const order = useChat((snapshot) => snapshot.order);
35
+ const nodes = useChat((snapshot) => snapshot.nodes);
36
+ const draft = useInput((state) => state.draft);
37
+ const live = React.useRef(null);
38
+ live.current = { order, nodes, draft, inputActions };
39
+ /** Last recalled entry; browsing lasts only while the draft still equals its text. */
40
+ const recalled = React.useRef(null);
41
+ const anchor = React.useRef(null);
42
+
43
+ React.useEffect(() => {
44
+ const seat = anchor.current.closest('[data-composer-seat]');
45
+ if (seat === null) return undefined;
46
+
47
+ const onKeyDown = (event) => {
48
+ const up = event.key === 'ArrowUp';
49
+ if (!up && event.key !== 'ArrowDown') return;
50
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || event.isComposing || event.keyCode === 229) return;
51
+ const input = event.target instanceof Element ? event.target.closest('[data-composer-input]') : null;
52
+ if (input === null || !input.isContentEditable || input.closest('[data-composer-seat]') !== seat) return;
53
+ if (seat.querySelector('[data-trigger-menu]') !== null) return; // an open slash/@ menu owns the arrows
54
+
55
+ const { order, nodes, draft, inputActions } = live.current;
56
+ const history = sentTexts(order, nodes);
57
+ const last = recalled.current;
58
+ // Browsing position; re-found by text when older history was prepended.
59
+ const at = last === null || draft !== last.text ? -1
60
+ : history[last.index] === draft ? last.index : history.lastIndexOf(draft);
61
+ if (at === -1 && (!up || draft !== '')) return; // not browsing: normal caret keys
62
+ // ponytail: lines are '\n' paragraphs; a soft-wrapped long line counts as one line.
63
+ // Like zsh, the caret first walks a multi-line entry and switches entries from its first/last line.
64
+ const { start, end } = inputActions.captureInsertion();
65
+ if (up ? draft.slice(0, start).includes('\n') : draft.slice(end).includes('\n')) return;
66
+
67
+ const next = up ? (at === -1 ? history.length - 1 : at - 1) : at + 1;
68
+ if (next < 0 && at === -1) return; // nothing sent yet
69
+ event.preventDefault();
70
+ event.stopPropagation();
71
+ if (next < 0) return; // already at the oldest entry
72
+ // Past the newest entry: back to the empty prompt.
73
+ const text = next < history.length ? history[next] : '';
74
+ recalled.current = text === '' ? null : { index: next, text };
75
+ inputActions.setDraft(text);
76
+ };
77
+
78
+ // Capture on the seat runs before the editor's own keydown handling.
79
+ seat.addEventListener('keydown', onKeyDown, true);
80
+ return () => {
81
+ seat.removeEventListener('keydown', onKeyDown, true);
82
+ };
83
+ }, []);
84
+
85
+ return React.createElement('span', { ref: anchor, hidden: true });
86
+ }
87
+
88
+ /** `/plan` at the very start (not `/planning`) plus at most one following space/tab. */
89
+ const PLAN_PREFIX = /^\/plan(?=$|\s)[ \t]?/;
90
+ /** Shift+Tab edit: drop a leading `/plan` command, otherwise prepend one. */
91
+ function planToggle(draft) {
92
+ const match = PLAN_PREFIX.exec(draft);
93
+ return match === null ? { start: 0, end: 0, text: '/plan ' } : { start: 0, end: match[0].length, text: '' };
94
+ }
95
+
96
+ /** Shift+Tab on every platform. */
97
+ const isPlanKey = (event) => event.key === 'Tab' && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
98
+
99
+ /** The plan key leaves plan mode when the chat is in it (as dsh's plan chip does: `/plan off`); otherwise it
100
+ * toggles a leading `/plan` and leaves the caret at the end. Rides `conversation.input.right` because, unlike
101
+ * the composer dock, it also renders on a blank chat. */
102
+ function PlanToggle({ useInput, inputActions, useProjection, exitPlanMode, notify }) {
103
+ const draft = useInput((state) => (state === undefined ? '' : state.draft));
104
+ const plan = typeof useProjection === 'function' ? useProjection('plan') : undefined;
105
+ // Same effective target as dsh's PlanChip: a pending switch flips the folded host value.
106
+ const planOn = plan !== undefined && (plan.pending ? !plan.active : plan.active);
107
+ const live = React.useRef(null);
108
+ live.current = { draft, inputActions, planOn, exitPlanMode, notify };
109
+ const anchor = React.useRef(null);
110
+
111
+ React.useEffect(() => {
112
+ const seat = anchor.current === null ? null : anchor.current.closest('[data-composer-seat]');
113
+ if (seat === null) return undefined;
114
+ const onKeyDown = (event) => {
115
+ if (!isPlanKey(event)) return;
116
+ const input = event.target instanceof Element ? event.target.closest('[data-composer-input]') : null;
117
+ if (input === null || !input.isContentEditable || input.closest('[data-composer-seat]') !== seat) return;
118
+ if (seat.querySelector('[data-trigger-menu]') !== null) return; // Shift+Tab closes an open slash/@ menu
119
+ event.preventDefault();
120
+ event.stopPropagation();
121
+ const { draft, inputActions, planOn, exitPlanMode, notify } = live.current;
122
+ if (event.repeat || event.isComposing || event.keyCode === 229) return;
123
+ if (planOn && exitPlanMode !== undefined) {
124
+ exitPlanMode().then((failure) => { if (failure !== null && notify !== undefined) notify('error', failure); },
125
+ (reason) => { if (notify !== undefined) notify('error', reason instanceof Error ? reason.message : String(reason)); });
126
+ return;
127
+ }
128
+ if (inputActions === undefined) return;
129
+ const { start, end, text } = planToggle(draft);
130
+ // Edit only the prefix so the rest of the draft, its file chips and attachments stay intact.
131
+ if (!inputActions.insertText(text, { ...inputActions.captureInsertion(), start, end })) return;
132
+ // Lexical commits the edit and its own caret asynchronously; move the caret after that commit.
133
+ requestAnimationFrame(() => {
134
+ if (!input.isConnected) return;
135
+ const range = document.createRange();
136
+ range.selectNodeContents(input);
137
+ range.collapse(false);
138
+ const selection = window.getSelection();
139
+ selection.removeAllRanges();
140
+ selection.addRange(range);
141
+ });
142
+ };
143
+ seat.addEventListener('keydown', onKeyDown, true);
144
+ return () => {
145
+ seat.removeEventListener('keydown', onKeyDown, true);
146
+ };
147
+ }, []);
148
+
149
+ return React.createElement('span', { ref: anchor, hidden: true });
150
+ }
151
+
152
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
153
+ /** dsh's canonical session URI: `dsh-session:` + base64url of the JSON-encoded id (as `dsh-session-reference`
154
+ * encodes it). Pasted into another chat, dsh attaches that session as read-only context. */
155
+ function sessionDeeplink(sessionId) {
156
+ let binary = '';
157
+ for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte);
158
+ return `dsh-session:${btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`;
159
+ }
160
+
161
+ /** "Copy deeplink" row in a session's "…" menu. */
162
+ function CopyDeeplinkItem({ sessionId, useMenuOpenState, t }) {
163
+ const [, setMenuOpen] = useMenuOpenState();
164
+ return React.createElement(primitives.MenuItemButton, {
165
+ icon: React.createElement(primitives.IconLinkOutlineRegular, {}),
166
+ onSelect: () => {
167
+ setMenuOpen(false);
168
+ primitives.writeClipboard(sessionDeeplink(sessionId));
169
+ },
170
+ }, t('copyDeeplink'));
171
+ }
172
+
173
+ /** Right-click on a session row opens its "…" menu; dsh rows have no context menu of their own.
174
+ * shortcut: finds the "…" button in the row's DOM; if a dsh update changes that markup, right-click falls back
175
+ * to the default menu. Upgrade when dsh adds a row context-menu slot. */
176
+ function RowContextMenu() {
177
+ const anchor = React.useRef(null);
178
+ React.useEffect(() => {
179
+ const row = anchor.current === null ? null : anchor.current.closest('[data-row-key^="session:"]');
180
+ if (row === null) return undefined;
181
+ const onContextMenu = (event) => {
182
+ // The "…" button is the row's only button outside a plugin slot.
183
+ const menuButton = [...row.querySelectorAll('button')].find((button) => {
184
+ const slot = button.closest('[data-slot]');
185
+ return slot === null || !row.contains(slot);
186
+ });
187
+ if (menuButton === undefined) return;
188
+ event.preventDefault();
189
+ menuButton.click();
190
+ };
191
+ row.addEventListener('contextmenu', onContextMenu);
192
+ return () => {
193
+ row.removeEventListener('contextmenu', onContextMenu);
194
+ };
195
+ }, []);
196
+ return React.createElement('span', { ref: anchor, hidden: true });
197
+ }
198
+
199
+ const CONTINUE_TEXT = 'continue';
200
+ /** Dictionary namespace for this plugin's menu copy; dsh ships `en` and `zh`. */
201
+ const LOCALE_NS = 'dsh-composer-plus';
202
+ let createPortal;
203
+ try { createPortal = require('react-dom').createPortal; } catch { createPortal = undefined; }
204
+ /** The composer's Send button (class `<hash>_primary`); the stop button shares it but only while running. */
205
+ function findSend(anchor) {
206
+ const seat = anchor.closest('[data-composer-seat]');
207
+ if (seat === null) return null;
208
+ const buttons = [...seat.querySelectorAll('button')].filter((b) => /(^|\s)\w+_primary(\s|$)/.test(b.className));
209
+ return buttons.length === 0 ? null : buttons[buttons.length - 1];
210
+ }
211
+ /** Codex-style resume: when the turn has stopped, the composer is empty and the chat has history,
212
+ * Send turns into a play button that submits "continue". Send is not a slot, so the play button is
213
+ * portaled into Send's row with Send's own class and Send is hidden meanwhile.
214
+ * Hidden on a blank chat, while running, in subagent views, or as soon as the user types or attaches. */
215
+ function ContinueButton({ useSession, useInput, inputActions }) {
216
+ const show = useSession((s) => s !== undefined && !s.blank && !s.running && s.subagent === null && !s.removed);
217
+ const idle = useInput((s) => s !== undefined && s.phase === 'plain' && s.draft.trim() === '' && s.attachmentIds.length === 0);
218
+ const visible = show && idle && inputActions !== undefined;
219
+ const anchorRef = React.useRef(null);
220
+ const [host, setHost] = React.useState(null);
221
+ React.useLayoutEffect(() => {
222
+ const send = !visible || anchorRef.current === null || createPortal === undefined ? null : findSend(anchorRef.current);
223
+ if (send === null) { setHost(null); return undefined; }
224
+ send.style.display = 'none';
225
+ setHost({ parent: send.parentElement, className: send.className });
226
+ return () => { send.style.display = ''; };
227
+ }, [visible]);
228
+ const anchor = React.createElement('span', { ref: anchorRef, hidden: true, 'aria-hidden': true });
229
+ if (!visible || host === null) return anchor;
230
+ const onClick = () => {
231
+ if (React.flushSync !== undefined) React.flushSync(() => inputActions.setDraft(CONTINUE_TEXT));
232
+ else inputActions.setDraft(CONTINUE_TEXT);
233
+ inputActions.submit();
234
+ };
235
+ const button = React.createElement('button', {
236
+ type: 'button',
237
+ className: host.className,
238
+ title: 'Continue',
239
+ 'aria-label': 'Continue',
240
+ onMouseDown: (event) => event.preventDefault(),
241
+ onClick,
242
+ }, React.createElement('svg', { viewBox: '0 0 16 16', width: 16, height: 16, 'aria-hidden': true },
243
+ React.createElement('path', { d: 'M4.5 2.6v10.8c0 .8.9 1.3 1.6.8l8-5.4c.6-.4.6-1.2 0-1.6l-8-5.4c-.7-.5-1.6 0-1.6.8Z', fill: 'currentColor' })));
244
+ return React.createElement(React.Fragment, null, anchor, createPortal(button, host.parent));
245
+ }
246
+
247
+ /** Text of an all-text queue row, or null when it carries images/files (queue edits are text-only). */
248
+ const textOf = (content) => (content.every((block) => block.type === 'text') ? content.map((block) => block.text).join('') : null);
249
+ const sameList = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
250
+ const GRIP = 'data-queue-grip';
251
+ const DROP_LINE = 'var(--dsw-alias-state-business-primary)';
252
+ /** Queue reorder by drag and drop: a grip at the left of each queued row. Keyboard: ↑/↓ on a focused grip.
253
+ * The Host has no move operation, so a move rotates row texts through the existing `edit` action, from the
254
+ * tail toward the head (the agent only claims the head); any failure reverts the edits already applied,
255
+ * so nothing is lost or duplicated.
256
+ * shortcut: grips are portaled into QueueDock rows found in the DOM; if a dsh update changes that markup the
257
+ * grips just stop appearing — upgrade when dsh exposes a queue row slot or a native move action. */
258
+ function QueueReorder({ useSession, useProjection, updateQueue, notify }) {
259
+ const inbox = useProjection('inbox');
260
+ const pending = useSession((s) => (s === undefined ? undefined : s.pendingSubmissions));
261
+ const mutable = useSession((s) => s !== undefined && (s.subagent === null || s.subagent.address.mode === 'continuable'));
262
+ const rows = React.useMemo(() => {
263
+ const all = (inbox && inbox['next-turn']) || [];
264
+ // Same filter as QueueDock: rows already echoed in the transcript are not shown in the dock.
265
+ const inChat = new Set((pending || []).filter((p) => p.placement === 'transcript').map((p) => p.requestId));
266
+ return all.filter(({ source }) => source.kind !== 'user' || !('rpcId' in source) || !inChat.has(source.rpcId));
267
+ }, [inbox, pending]);
268
+ const anchorRef = React.useRef(null);
269
+ // Rows plus each row's grip host (null while the row is being edited); re-scanned on every dock mutation.
270
+ const [dom, setDom] = React.useState({ items: [], hosts: [] });
271
+ const items = dom.items;
272
+ const [busy, setBusy] = React.useState(false);
273
+ const active = createPortal !== undefined && updateQueue !== undefined && mutable && rows.length >= 2;
274
+ React.useLayoutEffect(() => {
275
+ const anchor = anchorRef.current;
276
+ const seat = anchor === null ? null : anchor.closest('[data-composer-seat]');
277
+ if (seat === null || !active) { setDom({ items: [], hosts: [] }); return undefined; }
278
+ const scan = () => {
279
+ const dock = seat.querySelector('[data-queue-dock]');
280
+ const lis = dock === null ? [] : [...dock.querySelectorAll('li:not([data-submission-echo])')];
281
+ for (const li of lis) {
282
+ if (li.firstElementChild !== null && li.firstElementChild.hasAttribute(GRIP)) continue;
283
+ const host = document.createElement('span');
284
+ host.setAttribute(GRIP, '');
285
+ host.style.display = 'contents';
286
+ li.insertBefore(host, li.firstChild);
287
+ }
288
+ // Editing swaps the row body; when it ends the grip must be portaled again, so hosts are part of the state.
289
+ const hosts = lis.map((li) => (li.querySelector('textarea') === null ? li.firstElementChild : null));
290
+ setDom((prev) => (sameList(prev.items, lis) && sameList(prev.hosts, hosts) ? prev : { items: lis, hosts }));
291
+ };
292
+ scan();
293
+ const observer = new MutationObserver(scan);
294
+ observer.observe(seat, { childList: true, subtree: true });
295
+ return () => {
296
+ observer.disconnect();
297
+ for (const host of seat.querySelectorAll(`[${GRIP}]`)) host.remove();
298
+ };
299
+ }, [active]);
300
+ const anchor = React.createElement('span', { ref: anchorRef, hidden: true, 'aria-hidden': true });
301
+ if (!active || items.length !== rows.length) return anchor;
302
+ const texts = rows.map((row) => textOf(row.content));
303
+ const movable = (from, to) => from !== to && to >= 0 && to < rows.length
304
+ && texts.slice(Math.min(from, to), Math.max(from, to) + 1).every((text) => text !== null);
305
+ const move = async (from, to) => {
306
+ if (busy || !movable(from, to)) return;
307
+ const order = texts.map((_, index) => index);
308
+ order.splice(to, 0, order.splice(from, 1)[0]);
309
+ const changes = [];
310
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
311
+ if (order[index] !== index) changes.push({ row: rows[index], next: texts[order[index]], prev: texts[index] });
312
+ }
313
+ const edit = (row, text) => updateQueue(row.id, { kind: 'edit', content: [{ type: 'text', text }] });
314
+ setBusy(true);
315
+ const done = [];
316
+ try {
317
+ for (const change of changes) {
318
+ await edit(change.row, change.next);
319
+ done.push(change);
320
+ }
321
+ } catch {
322
+ for (const change of done.reverse()) await edit(change.row, change.prev).catch(() => undefined);
323
+ notify('error', 'Reorder failed: a message may have already started sending.');
324
+ } finally {
325
+ setBusy(false);
326
+ }
327
+ };
328
+ const mark = (target, from, to) => {
329
+ items.forEach((li) => { li.style.boxShadow = ''; li.style.opacity = ''; });
330
+ if (target === null) return;
331
+ items[from].style.opacity = '0.5';
332
+ if (to !== from) items[to].style.boxShadow = `inset 0 ${to < from ? 2 : -2}px 0 ${DROP_LINE}`;
333
+ };
334
+ const startDrag = (from, event) => {
335
+ if (busy || event.button !== 0) return;
336
+ event.preventDefault();
337
+ let to = from;
338
+ const slot = (y) => {
339
+ // Insertion index among the other rows, by row midpoints.
340
+ let index = 0;
341
+ items.forEach((li, i) => {
342
+ if (i === from) return;
343
+ const rect = li.getBoundingClientRect();
344
+ if (y > rect.top + rect.height / 2) index += 1;
345
+ });
346
+ return index;
347
+ };
348
+ const onMove = (e) => {
349
+ const next = slot(e.clientY);
350
+ to = movable(from, next) ? next : from;
351
+ mark(true, from, to);
352
+ document.body.style.cursor = 'grabbing';
353
+ };
354
+ const finish = (commit) => {
355
+ window.removeEventListener('pointermove', onMove);
356
+ window.removeEventListener('pointerup', onUp);
357
+ window.removeEventListener('pointercancel', onCancel);
358
+ window.removeEventListener('keydown', onKey, true);
359
+ document.body.style.cursor = '';
360
+ mark(null);
361
+ if (commit && to !== from) move(from, to);
362
+ };
363
+ const onUp = () => finish(true);
364
+ const onCancel = () => finish(false);
365
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); finish(false); } };
366
+ window.addEventListener('pointermove', onMove);
367
+ window.addEventListener('pointerup', onUp);
368
+ window.addEventListener('pointercancel', onCancel);
369
+ window.addEventListener('keydown', onKey, true);
370
+ mark(true, from, from);
371
+ };
372
+ const portals = rows.map((row, index) => {
373
+ const host = dom.hosts[index];
374
+ if (host === null || !host.hasAttribute(GRIP) || !host.isConnected) return null;
375
+ const draggable = texts[index] !== null && !busy;
376
+ const grip = React.createElement('button', {
377
+ type: 'button',
378
+ title: draggable ? 'Drag to reorder (or ↑/↓)' : 'Messages with images/files cannot be moved',
379
+ 'aria-label': 'Reorder queued message',
380
+ disabled: !draggable,
381
+ onPointerDown: (event) => startDrag(index, event),
382
+ onKeyDown: (event) => {
383
+ const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
384
+ if (delta === 0) return;
385
+ event.preventDefault();
386
+ move(index, index + delta);
387
+ },
388
+ style: { flex: 'none', display: 'grid', placeItems: 'center', width: 16, height: 24, padding: 0, marginLeft: -6,
389
+ border: 'none', background: 'none', color: 'var(--dsw-alias-label-tertiary)', cursor: draggable ? 'grab' : 'default',
390
+ opacity: draggable ? 1 : 0.35, touchAction: 'none' },
391
+ }, React.createElement('svg', { viewBox: '0 0 10 16', width: 10, height: 16, 'aria-hidden': true },
392
+ ...[3, 8, 13].flatMap((y) => [2.5, 7.5].map((x) => React.createElement('circle', { key: `${x}-${y}`, cx: x, cy: y, r: 1.3, fill: 'currentColor' })))));
393
+ return createPortal(grip, host, row.id);
394
+ });
395
+ return React.createElement(React.Fragment, null, anchor, ...portals.filter((portal) => portal !== null));
396
+ }
397
+
398
+ /** Auto Review per-chat tool grants: session id -> exact tool names (a fresh Set per change, so React sees it).
399
+ * Saved by the Host half so they survive a restart; revoking deletes them there too. Only Auto Review denials that reached the user are
400
+ * answered, through the approval card's own `answer('allowed-once')`; review failures never get that far. */
401
+ const grants = new Map();
402
+ const grantListeners = new Set();
403
+ const subscribeGrants = (listener) => { grantListeners.add(listener); return () => { grantListeners.delete(listener); }; };
404
+ const grantsChanged = () => { for (const listener of [...grantListeners]) listener(); };
405
+ const isGranted = (sessionId, toolName) => grants.get(sessionId)?.has(toolName) === true;
406
+ /** Host-side copy of the grants (`/api/composer-plus.grants`) so they survive a restart. Absent host half: memory only. */
407
+ const GRANTS_ROUTE = '/api/composer-plus.grants';
408
+ const saveGrant = (body) => {
409
+ try { fetch(GRANTS_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(() => {}); } catch { /* best-effort */ }
410
+ };
411
+ async function loadGrants() {
412
+ try {
413
+ const response = await fetch(GRANTS_ROUTE);
414
+ if (!response.ok) return;
415
+ const data = await response.json();
416
+ if (data === null || typeof data !== 'object') return;
417
+ for (const [sessionId, tools] of Object.entries(data)) {
418
+ if (Array.isArray(tools)) grants.set(sessionId, new Set([...(grants.get(sessionId) ?? []), ...tools.filter((x) => typeof x === 'string')]));
419
+ }
420
+ grantsChanged();
421
+ } catch { /* host half missing or offline: grants stay in memory */ }
422
+ }
423
+ function grant(sessionId, toolName) {
424
+ grants.set(sessionId, new Set([...(grants.get(sessionId) ?? []), toolName]));
425
+ grantsChanged();
426
+ saveGrant({ action: 'grant', sessionId, tool: toolName });
427
+ }
428
+ function revoke(sessionId) {
429
+ if (grants.delete(sessionId)) grantsChanged();
430
+ saveGrant({ action: 'revoke', sessionId });
431
+ }
432
+ const AUTO_REVIEW_ASK = 'Auto review denied tool ';
433
+ const isAutoReviewAsk = (p) => p !== undefined && p !== null && p.kind === 'approval' && typeof p.reason === 'string' && p.reason.startsWith(AUTO_REVIEW_ASK);
434
+ /** Requests this plugin already answered (or is answering), so concurrent updates never answer twice. */
435
+ const claimed = new WeakSet();
436
+ /** Allow every pending Auto Review ask whose exact tool is granted in its own chat. */
437
+ function answerGranted(snapshot) {
438
+ for (const [sessionId, status] of snapshot) {
439
+ const p = status === undefined ? undefined : status.pendingInteraction;
440
+ if (!isAutoReviewAsk(p) || p.sessionId !== sessionId || !p.answerable || claimed.has(p) || !isGranted(sessionId, p.toolName)) continue;
441
+ claimed.add(p);
442
+ p.answer('allowed-once').catch(() => {}); // already settled elsewhere: nothing to do
443
+ }
444
+ }
445
+
446
+ /** "Allow for this chat" on the Auto Review approval card, plus a header chip that lists and revokes the grants.
447
+ * shortcut: the button is portaled into the card's button row found in the DOM (`data-approval-key`); if a dsh
448
+ * update changes that markup the button just stops appearing. Upgrade when dsh adds an approval action slot. */
449
+ function ChatToolGrants({ sessionId, useSessionStatus, t }) {
450
+ const pending = useSessionStatus((snapshot) => snapshot.get(sessionId)?.pendingInteraction);
451
+ const granted = React.useSyncExternalStore(subscribeGrants, () => grants.get(sessionId));
452
+ const ask = isAutoReviewAsk(pending) && pending.sessionId === sessionId && !isGranted(sessionId, pending.toolName) ? pending : null;
453
+ const [host, setHost] = React.useState(null);
454
+ const [busy, setBusy] = React.useState(false);
455
+ React.useEffect(() => {
456
+ setHost(null);
457
+ setBusy(false);
458
+ if (ask === null || createPortal === undefined) return undefined;
459
+ let frame = 0;
460
+ const find = () => {
461
+ const card = document.querySelector(`[data-approval-key="${CSS.escape(ask.key)}"]`);
462
+ const buttons = card === null ? [] : card.querySelectorAll('button');
463
+ if (buttons.length > 0) setHost(buttons[buttons.length - 1].parentElement); // Reject / Allow once row
464
+ else frame = requestAnimationFrame(find);
465
+ };
466
+ find();
467
+ return () => { cancelAnimationFrame(frame); };
468
+ }, [ask]);
469
+ const allow = () => {
470
+ if (busy || claimed.has(ask) || !ask.answerable) return;
471
+ setBusy(true);
472
+ claimed.add(ask);
473
+ // Grant only after this click really answered the request; a cancelled or already answered one throws.
474
+ ask.answer('allowed-once').then(() => { grant(sessionId, ask.toolName); }, () => { setBusy(false); });
475
+ };
476
+ const tools = [...(granted ?? [])];
477
+ const chip = tools.length === 0 ? null : React.createElement(primitives.Button, {
478
+ variant: 'toolbar',
479
+ size: 'sm',
480
+ title: t('grantsTitle', { tools: tools.join('\n') }),
481
+ onClick: () => { revoke(sessionId); },
482
+ }, t('grants', { count: String(tools.length) }));
483
+ const button = ask === null || host === null ? null : createPortal(React.createElement(primitives.Button, {
484
+ variant: 'outline',
485
+ disabled: busy,
486
+ title: t('allowChatTitle', { toolName: ask.toolName }),
487
+ style: { order: -1, marginRight: 'auto' },
488
+ onClick: allow,
489
+ }, t('allowChat')), host, ask.key);
490
+ return chip === null && button === null ? null : React.createElement(React.Fragment, null, chip, button);
491
+ }
492
+
493
+ /** macOS/OS notifications while dsh is in the background: a turn finished, or a chat waits for an approval,
494
+ * a question or a plan review. Built on dsh's own Session status (`running`, `pendingInteraction`);
495
+ * clicking a notification focuses dsh and opens that chat. Uses the web Notification API, which the desktop
496
+ * app permits. Nothing is shown while the dsh window is focused. */
497
+ function attentionKind(previous, status) {
498
+ const pending = status === undefined ? undefined : status.pendingInteraction;
499
+ if (pending !== undefined && pending !== null && (previous === undefined || previous.pendingInteraction !== pending)) {
500
+ return pending.kind === 'approval' ? 'approval' : pending.kind === 'plan-review' ? 'planReview' : 'question';
501
+ }
502
+ // dsh sets `completionUnread` only for chats that are not open, so the open chat's finish is its running → idle edge.
503
+ if (status !== undefined && previous !== undefined && previous.running === true && status.running === false && pending == null) return 'done';
504
+ return null;
505
+ }
506
+ /** First non-empty line of a text, trimmed to a notification-friendly length. */
507
+ const firstLine = (text, max = 140) => {
508
+ const line = String(text ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
509
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
510
+ };
511
+ /** Notification body: the tool an approval is for, a question's first line, or the reply's opening line. */
512
+ function attentionBody(kind, pending, reply, t) {
513
+ if (kind === 'approval' && pending?.toolName) return t('notify.approvalTool', { tool: pending.toolName });
514
+ if (kind === 'question') {
515
+ const q = pending?.questions?.[0];
516
+ const text = firstLine(q?.question ?? q?.header);
517
+ if (text !== '') return t('notify.questionText', { text });
518
+ }
519
+ if (kind === 'done') {
520
+ const text = firstLine(reply);
521
+ if (text !== '') return text;
522
+ }
523
+ return t(`notify.${kind}`);
524
+ }
525
+ /** Latest reply preview from dsh's `turnOutline` projection, when the session list carries it. */
526
+ const replyOf = (sessionsList, sessionId) => {
527
+ const outline = sessionsList.getSnapshot().projectionsBySession?.[sessionId]?.values?.turnOutline;
528
+ return Array.isArray(outline) && outline.length > 0 ? outline[outline.length - 1]?.response : undefined;
529
+ };
530
+ function watchAttention(status, sessionsList, t, open) {
531
+ let last = status.getSnapshot();
532
+ const shown = new Map();
533
+ const notify = () => {
534
+ const next = status.getSnapshot();
535
+ const before = last;
536
+ last = next;
537
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted' || document.hasFocus()) return;
538
+ for (const [sessionId, current] of next) {
539
+ const kind = attentionKind(before.get(sessionId), current);
540
+ if (kind === null) continue;
541
+ const row = sessionsList.getSnapshot().byId[sessionId];
542
+ if (row !== undefined && row.origin === 'subagent') continue; // the parent chat reports for its children
543
+ const title = row === undefined ? 'DeepSeek Harness' : row.displayTitle;
544
+ shown.get(sessionId)?.close();
545
+ const body = attentionBody(kind, current.pendingInteraction, kind === 'done' ? replyOf(sessionsList, sessionId) : undefined, t);
546
+ const n = new Notification(title, { body, tag: `dsh:${sessionId}`, silent: false });
547
+ n.onclick = () => { window.focus(); open(sessionId); n.close(); unread.delete(sessionId); badge(); };
548
+ shown.set(sessionId, n);
549
+ unread.add(sessionId);
550
+ }
551
+ badge();
552
+ };
553
+ // Dock / taskbar badge: chats that notified while dsh was in the background. Cleared when dsh is focused.
554
+ const unread = new Set();
555
+ const badge = () => {
556
+ try {
557
+ if (unread.size === 0) navigator.clearAppBadge?.()?.catch?.(() => {});
558
+ else navigator.setAppBadge?.(unread.size)?.catch?.(() => {});
559
+ } catch { /* badges are best-effort */ }
560
+ };
561
+ const onFocus = () => { if (unread.size > 0) { unread.clear(); badge(); } };
562
+ window.addEventListener('focus', onFocus);
563
+ // Delivered notifications stay in Notification Center until clicked; only a newer one for the same chat replaces them.
564
+ const off = status.subscribe(notify);
565
+ return () => { off(); window.removeEventListener('focus', onFocus); unread.clear(); badge(); };
566
+ }
567
+
568
+ /** Codex-style "Changes" page in the right sidebar: Uncommitted / Unstaged / Staged / Branch (vs upstream) with
569
+ * per-file diffs from the Host half, plus "Last turn", which opens dsh's own turn review tab. */
570
+ const CHANGES_KIND = 'composer-plus-changes';
571
+ const CHANGES_ID = 'dsh-composer-plus/changes';
572
+ const CHANGE_MODES = ['uncommitted', 'unstaged', 'staged', 'branch'];
573
+ const changesUrl = (sessionId, mode) => `/api/composer-plus.changes?${new URLSearchParams({ sessionId, mode })}`;
574
+ const diffUrl = (sessionId, mode, path) => `/api/composer-plus.diff?${new URLSearchParams({ sessionId, mode, path })}`;
575
+ const LINE_COLORS = {
576
+ '+': { background: 'var(--dsw-alias-state-success-tertiary, rgba(46,160,67,.15))' },
577
+ '-': { background: 'var(--dsw-alias-state-error-tertiary, rgba(248,81,73,.15))' },
578
+ '@': { color: 'var(--dsw-alias-label-tertiary)' },
579
+ };
580
+ /** Unified patch body (from the first hunk on) as rows; headers are dropped. */
581
+ function patchRows(patch) {
582
+ const lines = String(patch ?? '').split('\n');
583
+ const start = lines.findIndex((l) => l.startsWith('@@'));
584
+ return start === -1 ? [] : lines.slice(start).filter((l, i, all) => !(i === all.length - 1 && l === ''));
585
+ }
586
+ function ChangesDiff({ sessionId, mode, file, t }) {
587
+ const [state, setState] = React.useState({ status: 'loading' });
588
+ React.useEffect(() => {
589
+ const ac = new AbortController();
590
+ setState({ status: 'loading' });
591
+ fetch(diffUrl(sessionId, mode, file.path), { signal: ac.signal })
592
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
593
+ .then((v) => setState({ status: 'ready', value: v }), () => { if (!ac.signal.aborted) setState({ status: 'error' }); });
594
+ return () => ac.abort();
595
+ }, [sessionId, mode, file.path, file.added, file.deleted]);
596
+ if (state.status === 'loading') return React.createElement('div', { style: { padding: '6px 12px', opacity: 0.7 } }, t('changes.loading'));
597
+ if (state.status === 'error') return React.createElement('div', { style: { padding: '6px 12px' } }, t('changes.diffFailed'));
598
+ if (file.binary) return React.createElement('div', { style: { padding: '6px 12px', opacity: 0.7 } }, t('changes.binary'));
599
+ const rows = patchRows(state.value.patch);
600
+ return React.createElement('pre', {
601
+ style: { margin: 0, padding: '4px 0', overflowX: 'auto', fontFamily: 'var(--ds-font-family-code)', fontSize: 12, lineHeight: '18px' },
602
+ }, rows.map((line, i) => React.createElement('div', { key: i, style: { padding: '0 12px', whiteSpace: 'pre', ...(LINE_COLORS[line[0]] ?? {}) } }, line === '' ? ' ' : line)),
603
+ state.value.truncated ? React.createElement('div', { style: { padding: '4px 12px', opacity: 0.7 } }, t('changes.truncated')) : null);
604
+ }
605
+ function ChangesTab({ sessionId, t, openLastTurn }) {
606
+ const [mode, setMode] = React.useState('uncommitted');
607
+ const [menuOpen, setMenuOpen] = React.useState(false);
608
+ const [state, setState] = React.useState({ status: 'loading' });
609
+ const [open, setOpen] = React.useState(() => new Set());
610
+ const [tick, setTick] = React.useState(0);
611
+ React.useEffect(() => {
612
+ const ac = new AbortController();
613
+ setState((prev) => (prev.status === 'ready' && prev.mode === mode ? prev : { status: 'loading' }));
614
+ fetch(changesUrl(sessionId, mode), { signal: ac.signal })
615
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
616
+ .then((v) => setState({ status: 'ready', mode, value: v }), () => { if (!ac.signal.aborted) setState({ status: 'error' }); });
617
+ return () => ac.abort();
618
+ }, [sessionId, mode, tick]);
619
+ // Refresh while visible: the agent edits files in the background.
620
+ React.useEffect(() => {
621
+ const timer = setInterval(() => { if (document.visibilityState === 'visible') setTick((n) => n + 1); }, 4000);
622
+ return () => clearInterval(timer);
623
+ }, []);
624
+ const v = state.status === 'ready' ? state.value : undefined;
625
+ const items = [
626
+ { id: 'last-turn', label: t('changes.mode.lastTurn'), disabled: v?.lastTurn === undefined },
627
+ { type: 'separator', id: 'sep1' },
628
+ ...CHANGE_MODES.slice(0, 3).map((id) => ({ id, label: t(`changes.mode.${id}`) })),
629
+ { type: 'separator', id: 'sep2' },
630
+ { id: 'branch', label: t('changes.mode.branch') },
631
+ ];
632
+ const pill = { display: 'inline-flex', alignItems: 'center', gap: 6, height: 28, padding: '0 12px', borderRadius: 14,
633
+ border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.25))', background: 'none', color: 'inherit', font: 'inherit', fontSize: 13, cursor: 'pointer' };
634
+ const trigger = React.createElement('button', { type: 'button', style: pill, 'aria-haspopup': 'menu', 'aria-expanded': menuOpen, onClick: () => setMenuOpen((x) => !x) },
635
+ t(`changes.mode.${mode}`), React.createElement('span', { 'aria-hidden': true, style: { opacity: 0.6 } }, '▾'));
636
+ const header = React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', flexWrap: 'wrap' } },
637
+ primitives.Menu === undefined ? trigger : React.createElement(primitives.Menu, {
638
+ open: menuOpen, anchor: trigger, items, selectedId: mode, selection: 'check', compact: true,
639
+ onClose: () => setMenuOpen(false),
640
+ onSelect: (id) => { setMenuOpen(false); if (id === 'last-turn') { if (v?.lastTurn) openLastTurn(sessionId, v.lastTurn); } else { setMode(id); setOpen(new Set()); } },
641
+ }),
642
+ v?.repo ? React.createElement('span', { style: { ...pill, cursor: 'default' }, title: v.root },
643
+ v.branch ?? 'HEAD', v.upstream ? React.createElement(React.Fragment, null, React.createElement('span', { style: { opacity: 0.5 } }, '→'), v.upstream) : null) : null,
644
+ React.createElement('span', { style: { flex: 1 } }),
645
+ React.createElement('button', { type: 'button', style: { ...pill, padding: '0 8px' }, title: t('changes.refresh'), 'aria-label': t('changes.refresh'), onClick: () => setTick((n) => n + 1) }, '⟳'));
646
+ let body;
647
+ if (state.status === 'loading') body = React.createElement('div', { style: { padding: 16, opacity: 0.7 } }, t('changes.loading'));
648
+ else if (state.status === 'error') body = React.createElement('div', { style: { padding: 16 } }, t('changes.failed'));
649
+ else if (!v.repo) body = React.createElement('div', { style: { padding: 16, opacity: 0.8 } }, t('changes.noRepo'));
650
+ else if (v.noUpstream) body = React.createElement('div', { style: { padding: 16, opacity: 0.8 } }, t('changes.noUpstream'));
651
+ else if (v.files.length === 0) body = React.createElement('div', { style: { padding: '48px 16px', textAlign: 'center' } },
652
+ React.createElement('div', { style: { fontSize: 15, fontWeight: 500 } }, t('changes.empty')),
653
+ React.createElement('div', { style: { opacity: 0.7, marginTop: 6 } }, t('changes.emptyHint')));
654
+ else body = React.createElement('div', null,
655
+ React.createElement('div', { style: { padding: '0 12px 8px', opacity: 0.8, fontSize: 13 } },
656
+ t('changes.summary', { count: String(v.total) }), ' ',
657
+ React.createElement('span', { style: { color: 'var(--dsw-alias-state-success-primary, #2ea043)' } }, `+${v.added}`), ' ',
658
+ React.createElement('span', { style: { color: 'var(--dsw-alias-state-error-primary, #f85149)' } }, `-${v.deleted}`)),
659
+ v.files.map((file) => {
660
+ const expanded = open.has(file.path);
661
+ const toggle = () => setOpen((prev) => { const next = new Set(prev); if (next.has(file.path)) next.delete(file.path); else next.add(file.path); return next; });
662
+ return React.createElement('div', { key: file.path, style: { borderTop: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.15))' } },
663
+ React.createElement('button', { type: 'button', onClick: toggle, 'aria-expanded': expanded,
664
+ style: { display: 'flex', width: '100%', alignItems: 'center', gap: 8, padding: '6px 12px', border: 'none', background: 'none', color: 'inherit', font: 'inherit', cursor: 'pointer', textAlign: 'left' } },
665
+ React.createElement('span', { 'aria-hidden': true, style: { width: 10, opacity: 0.6 } }, expanded ? '▾' : '▸'),
666
+ React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--ds-font-family-code)', fontSize: 12 }, title: file.from ? `${file.from} → ${file.path}` : file.path },
667
+ file.from ? `${file.from} → ${file.path}` : file.path),
668
+ file.untracked ? React.createElement('span', { style: { fontSize: 11, opacity: 0.7 } }, t('changes.untracked')) : null,
669
+ file.binary ? React.createElement('span', { style: { fontSize: 11, opacity: 0.7 } }, t('changes.binary'))
670
+ : React.createElement('span', { style: { fontSize: 12, whiteSpace: 'nowrap' } },
671
+ React.createElement('span', { style: { color: 'var(--dsw-alias-state-success-primary, #2ea043)' } }, `+${file.added}`), ' ',
672
+ React.createElement('span', { style: { color: 'var(--dsw-alias-state-error-primary, #f85149)' } }, `-${file.deleted}`))),
673
+ expanded ? React.createElement(ChangesDiff, { sessionId, mode, file, t }) : null);
674
+ }),
675
+ v.total > v.files.length ? React.createElement('div', { style: { padding: 12, opacity: 0.7 } }, t('changes.more', { count: String(v.total - v.files.length) })) : null);
676
+ return React.createElement('section', { 'data-composer-plus-changes': '', style: { height: '100%', overflowY: 'auto', fontSize: 13 } }, header, body);
677
+ }
678
+
679
+ return {
680
+ inject: ['slots', 'sessions', 'locale', 'remote', 'remote.commands'],
681
+ apply(ctx) {
682
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
683
+ name: 'conversation.input.dock',
684
+ id: 'queue-reorder',
685
+ order: 21,
686
+ inject: (sessionId) => {
687
+ const actx = ctx.sessions.scope(sessionId);
688
+ const conversation = actx === undefined ? undefined : actx.get('conversation');
689
+ return {
690
+ updateQueue: conversation === undefined ? undefined : (itemId, action) => conversation.updateQueue(itemId, action),
691
+ notify: (level, text) => { try { conversation.input.for(actx).notify(level, text); } catch { /* notice is best-effort */ } },
692
+ };
693
+ },
694
+ }, QueueReorder));
695
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
696
+ name: 'conversation.input.right',
697
+ id: 'composer-plan-toggle',
698
+ order: 999,
699
+ inject: (sessionId) => {
700
+ const commands = ctx.remote === undefined ? undefined : ctx.remote.commands;
701
+ const actx = ctx.sessions === undefined ? undefined : ctx.sessions.scope(sessionId);
702
+ return {
703
+ exitPlanMode: commands === undefined ? undefined : async () => {
704
+ const result = await commands.execute(sessionId, '/plan off', []);
705
+ if (!result.ok) return `${result.error.message} (${result.error.code})`;
706
+ return result.value === undefined ? 'unknown command: /plan off' : null;
707
+ },
708
+ notify: (level, text) => { try { actx.get('conversation').input.for(actx).notify(level, text); } catch { /* notice is best-effort */ } },
709
+ };
710
+ },
711
+ }, PlanToggle));
712
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
713
+ name: 'conversation.input.right',
714
+ id: 'composer-continue',
715
+ order: 1000,
716
+ }, ContinueButton));
717
+ if (ctx.locale !== undefined) ctx.effect(() => ctx.locale.register(LOCALE_NS, {
718
+ en: {
719
+ copyDeeplink: 'Copy deeplink',
720
+ allowChat: 'Allow for this chat',
721
+ allowChatTitle: 'Allow {toolName} without asking for the rest of this chat, with any arguments. If the tool can write, that includes any write.',
722
+ grants: '{count} tool(s) allowed · Revoke',
723
+ grantsTitle: 'Allowed in this chat without asking:\n{tools}\nClick to revoke all.',
724
+ 'notify.done': 'Finished.',
725
+ 'notify.approval': 'Waiting for your approval.',
726
+ 'notify.question': 'Has a question for you.',
727
+ 'notify.planReview': 'Plan ready for review.',
728
+ 'notify.approvalTool': 'Approve {tool}?',
729
+ 'notify.questionText': 'Question: {text}',
730
+ 'changes.title': 'Changes',
731
+ 'changes.guide': 'Changes',
732
+ 'changes.guideDescription': 'Uncommitted, staged and branch changes of this chat\'s repository',
733
+ 'changes.mode.lastTurn': 'Last Turn',
734
+ 'changes.mode.uncommitted': 'Uncommitted',
735
+ 'changes.mode.unstaged': 'Unstaged',
736
+ 'changes.mode.staged': 'Staged',
737
+ 'changes.mode.branch': 'Branch',
738
+ 'changes.loading': 'Loading…',
739
+ 'changes.failed': 'Could not read changes.',
740
+ 'changes.diffFailed': 'Could not load this diff.',
741
+ 'changes.noRepo': 'This chat\'s folder is not a git repository.',
742
+ 'changes.noUpstream': 'This branch has no upstream to compare with.',
743
+ 'changes.empty': 'No file changes yet',
744
+ 'changes.emptyHint': 'Changes in this project will appear here.',
745
+ 'changes.summary': '{count} file(s)',
746
+ 'changes.untracked': 'new',
747
+ 'changes.binary': 'binary',
748
+ 'changes.truncated': 'Diff truncated.',
749
+ 'changes.more': '{count} more file(s) not shown',
750
+ 'changes.refresh': 'Refresh',
751
+ },
752
+ zh: {
753
+ copyDeeplink: '复制深层链接',
754
+ allowChat: '本对话内允许',
755
+ allowChatTitle: '本对话剩余时间内,{toolName} 的任何参数调用都无需再询问。若该工具可写入,也包括任何写入操作。',
756
+ grants: '已允许 {count} 个工具 · 撤销',
757
+ grantsTitle: '本对话内无需询问即可使用:\n{tools}\n点击撤销全部。',
758
+ 'notify.done': '已完成。',
759
+ 'notify.approval': '等待你的批准。',
760
+ 'notify.question': '有问题需要你回答。',
761
+ 'notify.planReview': '计划已就绪,等待审阅。',
762
+ 'notify.approvalTool': '是否批准 {tool}?',
763
+ 'notify.questionText': '问题:{text}',
764
+ 'changes.title': '改动',
765
+ 'changes.guide': '改动',
766
+ 'changes.guideDescription': '查看本对话仓库的未提交、已暂存和分支改动',
767
+ 'changes.mode.lastTurn': '上一轮',
768
+ 'changes.mode.uncommitted': '未提交',
769
+ 'changes.mode.unstaged': '未暂存',
770
+ 'changes.mode.staged': '已暂存',
771
+ 'changes.mode.branch': '分支',
772
+ 'changes.loading': '加载中…',
773
+ 'changes.failed': '无法读取改动。',
774
+ 'changes.diffFailed': '无法加载此差异。',
775
+ 'changes.noRepo': '本对话的目录不是 git 仓库。',
776
+ 'changes.noUpstream': '当前分支没有可比较的上游分支。',
777
+ 'changes.empty': '暂无文件改动',
778
+ 'changes.emptyHint': '本项目的改动会显示在这里。',
779
+ 'changes.summary': '{count} 个文件',
780
+ 'changes.untracked': '新',
781
+ 'changes.binary': '二进制',
782
+ 'changes.truncated': '差异已截断。',
783
+ 'changes.more': '还有 {count} 个文件未显示',
784
+ 'changes.refresh': '刷新',
785
+ },
786
+ }), 'dsh-composer-plus: dictionaries');
787
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
788
+ name: 'conversation.session.header.utilities',
789
+ id: 'chat-tool-grants',
790
+ order: 0,
791
+ locale: LOCALE_NS,
792
+ }, ChatToolGrants));
793
+ if (ctx.inject !== undefined) ctx.inject(['uiSession', 'uiWorkspace', 'sessions'], (scope) => {
794
+ const t = ctx.locale.bind(LOCALE_NS);
795
+ scope.effect(() => watchAttention(scope.uiSession.sessionStatus, scope.sessions.list, t,
796
+ (sessionId) => scope.uiWorkspace.openSession(sessionId)), 'dsh-composer-plus: attention notifications');
797
+ // First use asks the OS once; a denied permission stays denied (re-enable it in System Settings).
798
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') Notification.requestPermission().catch(() => {});
799
+ });
800
+ if (ctx.inject !== undefined) ctx.inject(['sidebarRight', 'sidebarRightTabs'], (scope) => {
801
+ const t = ctx.locale.bind(LOCALE_NS);
802
+ scope.effect(() => scope.sidebarRightTabs.register({
803
+ id: CHANGES_ID,
804
+ kind: CHANGES_KIND,
805
+ priority: 'extension',
806
+ title: () => t('changes.title'),
807
+ guide: [{ id: 'open', order: 25, title: () => t('changes.guide'), description: () => t('changes.guideDescription'),
808
+ ...(primitives.GuideArtworkFiles === undefined ? {} : { icon: primitives.GuideArtworkFiles }) }],
809
+ }), 'dsh-composer-plus: changes tab type');
810
+ scope.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
811
+ name: 'sidebar.right.pane.tab',
812
+ key: CHANGES_ID,
813
+ locale: LOCALE_NS,
814
+ inject: () => ({
815
+ // "Last turn" reuses dsh's own changes-review tab for the newest recorded turn.
816
+ openLastTurn: (sessionId, { seq, turn }) => scope.sidebarRight.openResource(`dsh-resource://changes-review/session/${encodeURIComponent(sessionId)}/${seq}/${turn}`),
817
+ }),
818
+ }, ChangesTab)), 'dsh-composer-plus: changes tab body');
819
+ });
820
+ if (ctx.inject !== undefined) ctx.inject(['uiSession'], (scope) => {
821
+ const status = scope.uiSession.sessionStatus;
822
+ // Deferred so answering never re-enters the status notification that triggered it.
823
+ const scan = () => { queueMicrotask(() => answerGranted(status.getSnapshot())); };
824
+ scope.effect(() => {
825
+ const offStatus = status.subscribe(scan);
826
+ const offGrants = subscribeGrants(scan);
827
+ scan();
828
+ return () => { offStatus(); offGrants(); };
829
+ }, 'dsh-composer-plus: chat tool grants');
830
+ });
831
+ if (ctx.effect !== undefined) ctx.effect(() => () => { grants.clear(); grantsChanged(); }, 'dsh-composer-plus: forget grants on unload');
832
+ if (typeof fetch === 'function') loadGrants();
833
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
834
+ name: 'sidebar.workspaces.session.menu.item',
835
+ id: 'copy-deeplink',
836
+ order: 350,
837
+ locale: LOCALE_NS,
838
+ }, CopyDeeplinkItem));
839
+ ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
840
+ name: 'sidebar.workspaces.session.row.action',
841
+ id: 'session-context-menu',
842
+ order: 0,
843
+ }, RowContextMenu));
844
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
845
+ name: 'conversation.composer.dock',
846
+ id: 'composer-history',
847
+ order: 20,
848
+ }, ComposerHistory));
849
+ },
850
+ };
851
+ },
852
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: composer-plus
3
+ name: dsh-composer-plus
package/index.js ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Host half of dsh-composer-plus.
3
+ * - Changes panel: read-only git status/diff for a session's working directory, served on the authenticated `/api` channel.
4
+ * - Persistent Auto Review grants: per-session tool names saved under the dsh home, so "Allow for this chat" survives a restart.
5
+ */
6
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export const name = 'dsh-composer-plus';
11
+ export const inject = ['connection', 'subprocess', 'sessionQuery'];
12
+
13
+ export const CHANGES_PATH = '/api/composer-plus.changes';
14
+ export const DIFF_PATH = '/api/composer-plus.diff';
15
+ export const GRANTS_PATH = '/api/composer-plus.grants';
16
+ export const MODES = ['uncommitted', 'unstaged', 'staged', 'branch'];
17
+ const MAX_OUTPUT = 4 * 1024 * 1024;
18
+ const MAX_FILES = 500;
19
+ const TIMEOUT_MS = 15000;
20
+
21
+ /** The git executable, resolved once per subprocess provider; the macOS Xcode stub counts as absent (it opens an installer). */
22
+ const gitPaths = new WeakMap();
23
+ async function gitPath(subprocess) {
24
+ if (!gitPaths.has(subprocess)) gitPaths.set(subprocess, (async () => {
25
+ const exe = await subprocess.resolveExecutable('git');
26
+ if (process.platform !== 'darwin' || exe !== '/usr/bin/git') return exe;
27
+ const probe = subprocess.spawn({ argv: ['/usr/bin/xcode-select', '-p'], cwd: homedir(), stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 1000 });
28
+ return (await probe.done).exitCode === 0 ? exe : null;
29
+ })().catch(() => null));
30
+ return gitPaths.get(subprocess);
31
+ }
32
+
33
+ /** Run read-only `git <args>` in cwd: argv only (never a shell), scrubbed config, no prompts, no optional locks. */
34
+ export async function runGit(subprocess, cwd, args, signal) {
35
+ const git = await gitPath(subprocess);
36
+ if (git === null) return { code: 127, stdout: '', truncated: false, stderr: 'git is not installed' };
37
+ const timeout = AbortSignal.timeout(TIMEOUT_MS);
38
+ const handle = subprocess.spawn({
39
+ argv: [git, ...args],
40
+ cwd,
41
+ stdio: { stdin: 'ignore', stdout: { maxBytes: MAX_OUTPUT }, stderr: { maxBytes: 16384 } },
42
+ graceMs: 1000,
43
+ signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
44
+ env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
45
+ });
46
+ const outcome = await handle.done;
47
+ const out = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false };
48
+ return { code: outcome.exitCode, stdout: out.text, truncated: out.lossy, stderr: handle.collected.stderr?.readFrom(0).text ?? '' };
49
+ }
50
+
51
+ /** `git diff` arguments for a mode, relative to `base` for branch mode. */
52
+ export function diffArgs(mode, base) {
53
+ if (mode === 'staged') return ['diff', '--cached'];
54
+ if (mode === 'unstaged') return ['diff'];
55
+ if (mode === 'branch') return ['diff', base];
56
+ return ['diff', 'HEAD'];
57
+ }
58
+
59
+ /** Parse `--numstat -z` (renames carry an empty path then old\0new). */
60
+ export function parseNumstat(text) {
61
+ const files = [];
62
+ const parts = text.split('\0');
63
+ for (let i = 0; i < parts.length; i++) {
64
+ const head = parts[i];
65
+ if (head === '') continue;
66
+ const [added, deleted, path] = head.split('\t');
67
+ let file = path;
68
+ let from;
69
+ if (path === '') { from = parts[++i]; file = parts[++i]; }
70
+ const binary = added === '-' && deleted === '-';
71
+ files.push({ path: file, ...(from === undefined ? {} : { from }), added: binary ? 0 : Number(added), deleted: binary ? 0 : Number(deleted), binary });
72
+ }
73
+ return files;
74
+ }
75
+
76
+ /** Repository facts and the changed files for one mode. Untracked files count as additions outside the staged view. */
77
+ export async function changes(subprocess, cwd, mode, signal) {
78
+ const top = await runGit(subprocess, cwd, ['rev-parse', '--show-toplevel'], signal);
79
+ if (top.code !== 0) return { repo: false };
80
+ const root = top.stdout.trim();
81
+ const branch = (await runGit(subprocess, root, ['rev-parse', '--abbrev-ref', 'HEAD'], signal)).stdout.trim();
82
+ const up = await runGit(subprocess, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], signal);
83
+ const upstream = up.code === 0 ? up.stdout.trim() : undefined;
84
+ const hasHead = (await runGit(subprocess, root, ['rev-parse', '--verify', '-q', 'HEAD'], signal)).code === 0;
85
+ let base;
86
+ if (mode === 'branch') {
87
+ if (upstream === undefined) return { repo: true, root, branch, mode, files: [], total: 0, noUpstream: true };
88
+ const mb = await runGit(subprocess, root, ['merge-base', 'HEAD', upstream], signal);
89
+ if (mb.code !== 0) return { repo: true, root, branch, upstream, mode, files: [], total: 0, noUpstream: true };
90
+ base = mb.stdout.trim();
91
+ }
92
+ // A repository without commits has no HEAD: compare against the empty tree.
93
+ const args = mode === 'uncommitted' && !hasHead ? ['diff', '--cached'] : diffArgs(mode, base);
94
+ const stat = await runGit(subprocess, root, [...args, '--numstat', '-z', '-M', '--no-color', '--no-ext-diff'], signal);
95
+ if (stat.code !== 0) throw new Error(`git diff failed: ${stat.stderr.trim()}`);
96
+ const files = parseNumstat(stat.stdout);
97
+ if (mode !== 'staged') {
98
+ const untracked = await runGit(subprocess, root, ['ls-files', '--others', '--exclude-standard', '-z'], signal);
99
+ for (const path of untracked.stdout.split('\0')) if (path !== '' && !files.some((f) => f.path === path)) files.push({ path, added: 0, deleted: 0, binary: false, untracked: true });
100
+ }
101
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
102
+ return {
103
+ repo: true, root, branch, ...(upstream === undefined ? {} : { upstream }), mode,
104
+ files: files.slice(0, MAX_FILES), total: files.length,
105
+ added: files.reduce((n, f) => n + f.added, 0), deleted: files.reduce((n, f) => n + f.deleted, 0),
106
+ };
107
+ }
108
+
109
+ /** Unified diff of one listed file; untracked files diff against /dev/null. */
110
+ export async function fileDiff(subprocess, cwd, mode, path, signal) {
111
+ const summary = await changes(subprocess, cwd, mode, signal);
112
+ if (!summary.repo) return undefined;
113
+ const file = summary.files.find((f) => f.path === path);
114
+ if (file === undefined) return undefined; // only paths git itself listed: no arbitrary file reads
115
+ if (file.untracked) {
116
+ const r = await runGit(subprocess, summary.root, ['diff', '--no-index', '--no-color', '--no-ext-diff', '--', '/dev/null', path], signal);
117
+ return { path, patch: r.stdout, truncated: r.truncated, binary: file.binary };
118
+ }
119
+ let base;
120
+ if (mode === 'branch') base = (await runGit(subprocess, summary.root, ['merge-base', 'HEAD', summary.upstream], signal)).stdout.trim();
121
+ const hasHead = (await runGit(subprocess, summary.root, ['rev-parse', '--verify', '-q', 'HEAD'], signal)).code === 0;
122
+ const args = mode === 'uncommitted' && !hasHead ? ['diff', '--cached'] : diffArgs(mode, base);
123
+ const paths = file.from === undefined ? [path] : [file.from, path];
124
+ const r = await runGit(subprocess, summary.root, [...args, '-M', '--no-color', '--no-ext-diff', '--', ...paths], signal);
125
+ return { path, patch: r.stdout, truncated: r.truncated, binary: file.binary };
126
+ }
127
+
128
+ /** Grants file: { [sessionId]: string[] } of exact tool names allowed without asking. */
129
+ export function grantsStore(file) {
130
+ let queue = Promise.resolve();
131
+ const read = async () => {
132
+ try {
133
+ const data = JSON.parse(await readFile(file, 'utf8'));
134
+ return data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {};
135
+ } catch { return {}; }
136
+ };
137
+ // Serialize writes and replace atomically so a crash never leaves a half-written file.
138
+ const update = (fn) => (queue = queue.then(async () => {
139
+ const data = await read();
140
+ fn(data);
141
+ await mkdir(dirname(file), { recursive: true });
142
+ const tmp = `${file}.${process.pid}.tmp`;
143
+ await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
144
+ await rename(tmp, file);
145
+ return data;
146
+ }));
147
+ return {
148
+ read,
149
+ grant: (sessionId, tool) => update((d) => { d[sessionId] = [...new Set([...(d[sessionId] ?? []), tool])]; }),
150
+ revoke: (sessionId) => update((d) => { delete d[sessionId]; }),
151
+ };
152
+ }
153
+
154
+ const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
155
+ const isText = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max;
156
+
157
+ export function apply(ctx) {
158
+ /** The session's own working directory (the endpoint never takes a path from the browser) and its newest
159
+ * `workspace/changes` event, which dsh's own review tab opens as "Last turn". */
160
+ const sessionFacts = async (sessionId, signal) => {
161
+ const observation = await ctx.sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
162
+ try {
163
+ let lastTurn;
164
+ const events = observation.events;
165
+ for (let i = events.length - 1; i >= 0; i--) {
166
+ const e = events[i];
167
+ if (e.type === 'workspace/changes' && Number.isSafeInteger(e.data?.turn)) { lastTurn = { seq: e.seq, turn: e.data.turn }; break; }
168
+ }
169
+ return { cwd: observation.header.cwd, lastTurn };
170
+ } finally { observation[Symbol.dispose]?.(); }
171
+ };
172
+ const cwdOf = async (sessionId, signal) => (await sessionFacts(sessionId, signal)).cwd;
173
+ const params = (request) => {
174
+ const q = new URL(request.url).searchParams;
175
+ const sessionId = q.get('sessionId');
176
+ const mode = q.get('mode') ?? 'uncommitted';
177
+ return isText(sessionId) && MODES.includes(mode) ? { sessionId, mode, path: q.get('path') } : undefined;
178
+ };
179
+ ctx.connection.fetch.register({
180
+ path: CHANGES_PATH, methods: ['GET'], requestBody: 'buffered',
181
+ fetch: async (request) => {
182
+ const p = params(request);
183
+ if (p === undefined) return json({ error: 'bad request' }, 400);
184
+ try {
185
+ const { cwd, lastTurn } = await sessionFacts(p.sessionId, request.signal);
186
+ const extra = lastTurn === undefined ? {} : { lastTurn };
187
+ if (cwd === undefined) return json({ repo: false, ...extra });
188
+ return json({ ...(await changes(ctx.subprocess, cwd, p.mode, request.signal)), ...extra });
189
+ } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
190
+ },
191
+ });
192
+ ctx.connection.fetch.register({
193
+ path: DIFF_PATH, methods: ['GET'], requestBody: 'buffered',
194
+ fetch: async (request) => {
195
+ const p = params(request);
196
+ if (p === undefined || !isText(p.path, 4096)) return json({ error: 'bad request' }, 400);
197
+ try {
198
+ const cwd = await cwdOf(p.sessionId, request.signal);
199
+ const diff = cwd === undefined ? undefined : await fileDiff(ctx.subprocess, cwd, p.mode, p.path, request.signal);
200
+ return diff === undefined ? json({ error: 'not found' }, 404) : json(diff);
201
+ } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
202
+ },
203
+ });
204
+ const store = grantsStore(join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-composer-plus', 'grants.json'));
205
+ ctx.connection.fetch.register({
206
+ path: GRANTS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
207
+ fetch: async (request) => {
208
+ if (request.method === 'GET') return json(await store.read());
209
+ let body;
210
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
211
+ if (body?.action === 'grant' && isText(body.sessionId) && isText(body.tool)) return json(await store.grant(body.sessionId, body.tool));
212
+ if (body?.action === 'revoke' && isText(body.sessionId)) return json(await store.revoke(body.sessionId));
213
+ return json({ error: 'bad request' }, 400);
214
+ },
215
+ });
216
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.1",
4
+ "description": "DeepSeek Harness helpers: Codex-style Changes panel, input history, Shift+Tab /plan, one-click Continue, queue drag-and-drop, Copy deeplink, Auto Review allow-for-chat, background notifications",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "changes",
9
+ "deepseek-harness",
10
+ "dsh",
11
+ "dsh-plugin",
12
+ "git",
13
+ "notifications",
14
+ "plugin"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": "./client.js"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "client.js",
23
+ "cordis.patch.yml",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "test": "node test/continue.test.mjs && node test/queue-drag.test.mjs && node test/plan-toggle.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs"
29
+ },
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "immediately": true,
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-locale",
39
+ "@deepseek-ai/dsh-client-ui-conversation",
40
+ "@deepseek-ai/dsh-client-ui-workspace",
41
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
42
+ "@deepseek-ai/dsh-client-ui-session",
43
+ "@deepseek-ai/dsh-api-remotes",
44
+ "@deepseek-ai/dsh-api-session-controller"
45
+ ]
46
+ }
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/furkancak1r/dsh-composer-plus.git"
51
+ },
52
+ "homepage": "https://github.com/furkancak1r/dsh-composer-plus#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/furkancak1r/dsh-composer-plus/issues"
55
+ },
56
+ "engines": {
57
+ "node": ">=22",
58
+ "dsh": ">=0.2.0-rc.2"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }