@itookit/dsht 0.1.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/LICENSE +21 -0
- package/README.i18n.yaml +3 -0
- package/README.md +214 -0
- package/README.zh.md +214 -0
- package/dist/app.d.ts +5 -0
- package/dist/app.js +353 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +108 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/client.d.ts +61 -0
- package/dist/client.js +230 -0
- package/dist/controller.d.ts +140 -0
- package/dist/controller.js +640 -0
- package/dist/cost-view.d.ts +8 -0
- package/dist/cost-view.js +20 -0
- package/dist/cost.d.ts +97 -0
- package/dist/cost.js +267 -0
- package/dist/endpoint.d.ts +16 -0
- package/dist/endpoint.js +16 -0
- package/dist/history.d.ts +17 -0
- package/dist/history.js +32 -0
- package/dist/input.d.ts +23 -0
- package/dist/input.js +103 -0
- package/dist/mouse.d.ts +14 -0
- package/dist/mouse.js +41 -0
- package/dist/navigation.d.ts +11 -0
- package/dist/navigation.js +36 -0
- package/dist/references.d.ts +25 -0
- package/dist/references.js +40 -0
- package/dist/status.d.ts +26 -0
- package/dist/status.js +135 -0
- package/dist/telemetry.d.ts +28 -0
- package/dist/telemetry.js +97 -0
- package/dist/transcript.d.ts +64 -0
- package/dist/transcript.js +279 -0
- package/dist/wire.d.ts +17 -0
- package/dist/wire.js +27 -0
- package/dsht.png +0 -0
- package/package.json +74 -0
package/dist/app.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/** Ink terminal interface: startup pickers, transcript, and slash-command composer. */
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
5
|
+
import { useMouseWheel } from "./mouse.js";
|
|
6
|
+
import { TextInput } from "./input.js";
|
|
7
|
+
import { historyLayout, jumpTarget } from "./history.js";
|
|
8
|
+
import { toolLine } from "./transcript.js";
|
|
9
|
+
import { activeReference, fileMention } from "./references.js";
|
|
10
|
+
import { CostPanel } from "./cost-view.js";
|
|
11
|
+
import { StatusBar } from "./status.js";
|
|
12
|
+
import { navigationCommand, sessionLabel } from "./navigation.js";
|
|
13
|
+
import { array, errorText, object, safeText, string } from "./wire.js";
|
|
14
|
+
const COMMANDS = ['/ws', '/s', '/new', '/older', '/history', '/jump', '/search', '/ssearch', '/wsearch', '/cancel', '/steer', '/allow', '/deny', '/status', '/cost', '/help', '/quit'];
|
|
15
|
+
const HELP = '/ws [name or ID] · /s [title or ID] · /s all · /new · /older · /history [text] · /jump <seq|first|last> · /search text · /ssearch text · /wsearch text · /cancel · /steer text · /allow · /deny · /status · /cost · /quit';
|
|
16
|
+
function Picker({ choices, enabled, canSelect }) {
|
|
17
|
+
const [selected, setSelected] = useState(0);
|
|
18
|
+
const current = Math.min(selected, choices.length - 1);
|
|
19
|
+
useInput((_input, key) => {
|
|
20
|
+
if (!canSelect())
|
|
21
|
+
return;
|
|
22
|
+
if (key.upArrow)
|
|
23
|
+
setSelected(Math.max(0, current - 1));
|
|
24
|
+
else if (key.downArrow)
|
|
25
|
+
setSelected(Math.min(choices.length - 1, current + 1));
|
|
26
|
+
else if (key.return)
|
|
27
|
+
choices[current]?.action();
|
|
28
|
+
}, { isActive: enabled });
|
|
29
|
+
const start = Math.max(0, current - 7);
|
|
30
|
+
return _jsxs(Box, { flexDirection: "column", children: [choices.slice(start, start + 12).map((choice, index) => _jsxs(Text, { color: start + index === current ? 'cyan' : undefined, children: [start + index === current ? '❯ ' : ' ', safeText(choice.label)] }, choice.key)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 select \u00B7 Enter open \u00B7 Ctrl+C stop / exit" })] });
|
|
31
|
+
}
|
|
32
|
+
/** The caller owns starting and stopping the controller around the Ink render lifetime. */
|
|
33
|
+
export function App({ controller }) {
|
|
34
|
+
const state = useSyncExternalStore(controller.subscribe, controller.snapshot);
|
|
35
|
+
const { exit } = useApp();
|
|
36
|
+
const { stdout } = useStdout();
|
|
37
|
+
const [input, updateInput] = useState('');
|
|
38
|
+
const draft = useRef('');
|
|
39
|
+
const [cursor, setCursor] = useState(0);
|
|
40
|
+
// Input callbacks may run before Ink refreshes the controlled field's listener.
|
|
41
|
+
const setInput = (value) => { draft.current = value; updateInput(value); setCursor(value.length); };
|
|
42
|
+
const [scroll, setScroll] = useState(0);
|
|
43
|
+
const [historyQuery, setHistoryQuery] = useState();
|
|
44
|
+
const [contentSearch, setContentSearch] = useState(false);
|
|
45
|
+
const [searchResults, setSearchResults] = useState();
|
|
46
|
+
const historyAbort = useRef(undefined);
|
|
47
|
+
useEffect(() => () => historyAbort.current?.abort(), []);
|
|
48
|
+
const [costExpanded, setCostExpanded] = useState(false);
|
|
49
|
+
const [statusExpanded, setStatusExpanded] = useState(false);
|
|
50
|
+
const [help, setHelp] = useState(false);
|
|
51
|
+
const [answers, setAnswers] = useState({});
|
|
52
|
+
const [referenceIndex, setReferenceIndex] = useState(0);
|
|
53
|
+
const [dismissedReference, dismissReference] = useState();
|
|
54
|
+
const [lookup, setLookup] = useState();
|
|
55
|
+
const pending = state.pending[0];
|
|
56
|
+
const token = state.screen === 'chat' && state.online && !state.busy && !pending
|
|
57
|
+
&& (!input.startsWith('/') || input.startsWith('/steer ')) && dismissedReference !== input && cursor === input.length
|
|
58
|
+
? activeReference(input) : undefined;
|
|
59
|
+
const referenceOpen = token !== undefined;
|
|
60
|
+
const matches = referenceOpen && lookup?.draft === input && lookup.sessionId === state.sessionId ? lookup : undefined;
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!referenceOpen)
|
|
63
|
+
return;
|
|
64
|
+
const abort = new AbortController();
|
|
65
|
+
setLookup(undefined);
|
|
66
|
+
setReferenceIndex(0);
|
|
67
|
+
void controller.references(activeReference(input).query, abort.signal).then(items => {
|
|
68
|
+
if (!abort.signal.aborted)
|
|
69
|
+
setLookup({ draft: input, sessionId: state.sessionId, items });
|
|
70
|
+
}, error => {
|
|
71
|
+
if (!abort.signal.aborted)
|
|
72
|
+
setLookup({ draft: input, sessionId: state.sessionId, items: [], error: errorText(error) });
|
|
73
|
+
});
|
|
74
|
+
return () => abort.abort();
|
|
75
|
+
}, [controller, input, state.sessionId, referenceOpen]);
|
|
76
|
+
const pickReference = () => {
|
|
77
|
+
const candidate = matches?.items[referenceIndex];
|
|
78
|
+
if (!candidate || !token || draft.current !== input)
|
|
79
|
+
return;
|
|
80
|
+
const mention = fileMention(candidate, token.quoted);
|
|
81
|
+
setInput(input.slice(0, -token.prefix.length) + mention + (candidate.kind === 'file' ? ' ' : ''));
|
|
82
|
+
};
|
|
83
|
+
const questions = pending?.event === 'user-questions/request' ? array(object(pending.request).questions).map(object) : [];
|
|
84
|
+
const eventId = pending ? string(pending.eventId) : '';
|
|
85
|
+
const answered = answers[eventId] ?? [];
|
|
86
|
+
const question = questions[answered.length];
|
|
87
|
+
const operate = (fn) => { void controller.perform(fn); };
|
|
88
|
+
useInput((_value, key) => {
|
|
89
|
+
if ((key.escape || key.ctrl && _value === 'c') && historyAbort.current) {
|
|
90
|
+
historyAbort.current.abort();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (key.escape && searchResults) {
|
|
94
|
+
setSearchResults(undefined);
|
|
95
|
+
if (controller.running)
|
|
96
|
+
void controller.interrupt(true);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (key.escape && historyQuery !== undefined) {
|
|
100
|
+
setHistoryQuery(undefined);
|
|
101
|
+
if (controller.running)
|
|
102
|
+
void controller.interrupt(true);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.ctrl && _value === 'c') {
|
|
106
|
+
void controller.interrupt().then(shouldExit => { if (shouldExit)
|
|
107
|
+
exit(); });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (referenceOpen) {
|
|
111
|
+
if (key.escape) {
|
|
112
|
+
dismissReference(input);
|
|
113
|
+
if (controller.running)
|
|
114
|
+
void controller.interrupt(true);
|
|
115
|
+
}
|
|
116
|
+
else if (key.tab)
|
|
117
|
+
pickReference();
|
|
118
|
+
else if (key.upArrow)
|
|
119
|
+
setReferenceIndex(value => Math.max(0, value - 1));
|
|
120
|
+
else if (key.downArrow)
|
|
121
|
+
setReferenceIndex(value => Math.max(0, Math.min((matches?.items.length ?? 1) - 1, value + 1)));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (key.escape && state.screen === 'chat') {
|
|
125
|
+
void controller.interrupt(true);
|
|
126
|
+
}
|
|
127
|
+
if (key.pageUp)
|
|
128
|
+
scrollHistory(10);
|
|
129
|
+
if (key.pageDown)
|
|
130
|
+
scrollHistory(-10);
|
|
131
|
+
});
|
|
132
|
+
const submit = async (raw) => {
|
|
133
|
+
if (referenceOpen) {
|
|
134
|
+
pickReference();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const value = raw.trim();
|
|
138
|
+
if (!value)
|
|
139
|
+
return;
|
|
140
|
+
if (value === '/quit') {
|
|
141
|
+
exit();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (value === '/cost') {
|
|
145
|
+
setCostExpanded(value => !value);
|
|
146
|
+
setInput('');
|
|
147
|
+
if (!costExpanded)
|
|
148
|
+
void controller.perform(() => historyOperation(signal => controller.refreshCosts(signal)));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (value === '/status') {
|
|
152
|
+
setStatusExpanded(value => !value);
|
|
153
|
+
setInput('');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (value === '/help') {
|
|
157
|
+
setHelp(value => !value);
|
|
158
|
+
setInput('');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const accepted = await controller.perform(async () => {
|
|
162
|
+
const navigation = navigationCommand(value);
|
|
163
|
+
if (navigation) {
|
|
164
|
+
setHistoryQuery(undefined);
|
|
165
|
+
setSearchResults(undefined);
|
|
166
|
+
if (navigation.kind === 'workspace')
|
|
167
|
+
await controller.switchWorkspace(navigation.query);
|
|
168
|
+
else
|
|
169
|
+
await controller.switchSession(navigation.query);
|
|
170
|
+
setScroll(0);
|
|
171
|
+
}
|
|
172
|
+
else if (state.screen === 'path')
|
|
173
|
+
await controller.createWorkspace(value);
|
|
174
|
+
else if (value === '/new')
|
|
175
|
+
await controller.createSession();
|
|
176
|
+
else if (value === '/history' || value.startsWith('/history ')) {
|
|
177
|
+
if (state.screen !== 'chat')
|
|
178
|
+
throw new Error('Select a session first');
|
|
179
|
+
setSearchResults(undefined);
|
|
180
|
+
setContentSearch(false);
|
|
181
|
+
setHistoryQuery(value.slice(8).trim());
|
|
182
|
+
}
|
|
183
|
+
else if (/^\/(?:search|ssearch|wsearch)(?: |$)/.test(value)) {
|
|
184
|
+
const [command, ...words] = value.split(' ');
|
|
185
|
+
const query = words.join(' ').trim();
|
|
186
|
+
if (!query)
|
|
187
|
+
throw new Error(`Use ${command} <text>`);
|
|
188
|
+
await historyOperation(async (signal) => {
|
|
189
|
+
if (command === '/search') {
|
|
190
|
+
await controller.historyThrough('first', signal);
|
|
191
|
+
setSearchResults(undefined);
|
|
192
|
+
setContentSearch(true);
|
|
193
|
+
setHistoryQuery(query);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const result = await controller.searchSessions(query, command === '/ssearch', signal);
|
|
197
|
+
setHistoryQuery(undefined);
|
|
198
|
+
setSearchResults({ query, ...result });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else if (value === '/jump' || value.startsWith('/jump '))
|
|
203
|
+
await jumpHistory(jumpTarget(value.slice(5).trim()));
|
|
204
|
+
else if (value === '/older') {
|
|
205
|
+
await controller.older();
|
|
206
|
+
setScroll(value => value + 10);
|
|
207
|
+
}
|
|
208
|
+
else if (value === '/cancel')
|
|
209
|
+
await controller.cancelTurn();
|
|
210
|
+
else if (value === '/allow')
|
|
211
|
+
await controller.approve(true);
|
|
212
|
+
else if (value === '/deny')
|
|
213
|
+
await controller.approve(false);
|
|
214
|
+
else if (value.startsWith('/steer '))
|
|
215
|
+
await controller.prompt(value.slice(7), 'steer');
|
|
216
|
+
else if (question) {
|
|
217
|
+
const answer = { id: string(question.id), selected: [], custom: value };
|
|
218
|
+
const next = [...answered, answer];
|
|
219
|
+
if (next.length === questions.length)
|
|
220
|
+
await controller.answer({ answers: next });
|
|
221
|
+
setAnswers(previous => ({ ...previous, [eventId]: next }));
|
|
222
|
+
}
|
|
223
|
+
else if (pending)
|
|
224
|
+
throw new Error('Answer the approval with /allow or /deny');
|
|
225
|
+
else if (value.startsWith('/'))
|
|
226
|
+
throw new Error('Unknown command. Use /help.');
|
|
227
|
+
else if (state.screen !== 'chat')
|
|
228
|
+
throw new Error('Choose a session or type /ws or /s');
|
|
229
|
+
else {
|
|
230
|
+
await controller.prompt(value);
|
|
231
|
+
setScroll(0);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (accepted)
|
|
235
|
+
setInput('');
|
|
236
|
+
};
|
|
237
|
+
const choices = state.screen === 'workspaces' ? [
|
|
238
|
+
...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
|
|
239
|
+
label: `${string(workspace.title)} ${string(workspace.path)}`,
|
|
240
|
+
action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
|
|
241
|
+
{ key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
|
|
242
|
+
{ key: '@new', label: '+ Add workspace (host directory)', action: () => controller.enterPath() },
|
|
243
|
+
] : [
|
|
244
|
+
...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
|
|
245
|
+
...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
|
|
246
|
+
label: `${session.running ? '● ' : ''}${sessionLabel(session)} ${session.sessionId}`,
|
|
247
|
+
action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
|
|
248
|
+
{ key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
|
|
249
|
+
];
|
|
250
|
+
const width = Math.max(10, (stdout.columns ?? 80) - 2);
|
|
251
|
+
const layout = useMemo(() => historyLayout(state.transcript, width), [state.transcript, state.transcript.version, width]);
|
|
252
|
+
const { lines, first } = layout;
|
|
253
|
+
const pageSize = Math.max(5, (stdout.rows ?? 30) - (pending ? 17 : 12) - (statusExpanded ? 9 : 0));
|
|
254
|
+
const previousView = useRef({ transcript: state.transcript, count: lines.length, first });
|
|
255
|
+
const previous = previousView.current;
|
|
256
|
+
const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
|
|
257
|
+
const adjustedScroll = previous.transcript !== state.transcript ? 0
|
|
258
|
+
: scroll > 0 && !prepended ? Math.max(0, scroll + lines.length - previous.count) : scroll;
|
|
259
|
+
const maxScroll = Math.max(0, lines.length - pageSize);
|
|
260
|
+
const position = Math.min(adjustedScroll, maxScroll);
|
|
261
|
+
useLayoutEffect(() => {
|
|
262
|
+
previousView.current = { transcript: state.transcript, count: lines.length, first };
|
|
263
|
+
if (position !== scroll)
|
|
264
|
+
setScroll(position);
|
|
265
|
+
}, [state.transcript, lines, position, scroll]);
|
|
266
|
+
const scrollPosition = useRef(position);
|
|
267
|
+
scrollPosition.current = position;
|
|
268
|
+
const loadingPage = useRef(false);
|
|
269
|
+
const scrollIntent = useRef(0);
|
|
270
|
+
const mounted = useRef(true);
|
|
271
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
272
|
+
function scrollHistory(delta) {
|
|
273
|
+
if (state.screen !== 'chat' || historyQuery !== undefined || searchResults !== undefined)
|
|
274
|
+
return;
|
|
275
|
+
const intent = ++scrollIntent.current;
|
|
276
|
+
const next = Math.max(0, Math.min(maxScroll, scrollPosition.current + delta));
|
|
277
|
+
scrollPosition.current = next;
|
|
278
|
+
setScroll(next);
|
|
279
|
+
if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !state.transcript.ready || !state.transcript.hasMore)
|
|
280
|
+
return;
|
|
281
|
+
loadingPage.current = true;
|
|
282
|
+
const transcript = state.transcript;
|
|
283
|
+
void controller.perform(() => historyOperation(signal => controller.older(signal))).then(accepted => {
|
|
284
|
+
if (accepted && mounted.current && controller.state.transcript === transcript && scrollIntent.current === intent) {
|
|
285
|
+
setScroll(value => value + delta);
|
|
286
|
+
}
|
|
287
|
+
}).finally(() => { loadingPage.current = false; });
|
|
288
|
+
}
|
|
289
|
+
async function historyOperation(operation) {
|
|
290
|
+
const abort = new AbortController();
|
|
291
|
+
historyAbort.current = abort;
|
|
292
|
+
try {
|
|
293
|
+
await operation(abort.signal);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
if (historyAbort.current === abort)
|
|
297
|
+
historyAbort.current = undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async function openSearchSession(sessionId, query) {
|
|
301
|
+
await historyOperation(async (signal) => {
|
|
302
|
+
await controller.selectSession(sessionId);
|
|
303
|
+
await controller.waitForHistory(signal);
|
|
304
|
+
await controller.historyThrough('first', signal);
|
|
305
|
+
setSearchResults(undefined);
|
|
306
|
+
setContentSearch(true);
|
|
307
|
+
setHistoryQuery(query);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async function jumpHistory(target) {
|
|
311
|
+
if (state.screen !== 'chat')
|
|
312
|
+
throw new Error('Select a session first');
|
|
313
|
+
++scrollIntent.current;
|
|
314
|
+
if (target === 'last') {
|
|
315
|
+
setHistoryQuery(undefined);
|
|
316
|
+
setScroll(0);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const abort = new AbortController();
|
|
320
|
+
historyAbort.current = abort;
|
|
321
|
+
try {
|
|
322
|
+
await controller.historyThrough(target, abort.signal);
|
|
323
|
+
abort.signal.throwIfAborted();
|
|
324
|
+
const current = historyLayout(controller.state.transcript, width);
|
|
325
|
+
const seq = target === 'first' ? current.messages[0]?.seq : target;
|
|
326
|
+
const row = seq === undefined ? undefined : current.offsets.get(seq);
|
|
327
|
+
if (row === undefined)
|
|
328
|
+
throw new Error('No visible message at this sequence; use /history to choose a record');
|
|
329
|
+
setHistoryQuery(undefined);
|
|
330
|
+
setScroll(Math.max(0, current.lines.length - pageSize - row));
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
if (historyAbort.current === abort)
|
|
334
|
+
historyAbort.current = undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
useMouseWheel(direction => scrollHistory(direction * 3));
|
|
338
|
+
const end = Math.max(pageSize, lines.length - position);
|
|
339
|
+
const visible = lines.slice(Math.max(0, end - pageSize), end);
|
|
340
|
+
const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
|
|
341
|
+
return _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "DeepSeek Harness \u00B7 HTTP TUI" }), _jsxs(Text, { dimColor: true, children: [controller.base, " \u00B7 ", state.status, state.busy ? ' · Working…' : ''] }), _jsx(Text, { wrap: "truncate-end", children: toolLine(`${workspace ? string(workspace.title) : 'All workspaces'}${state.sessionId ? ` / ${controller.sessionName}` : ''}`, Math.max(1, width - 4)) })] }), state.error && _jsx(Text, { color: "red", children: state.error }), searchResults ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: ["Session search \u00B7 ", safeText(searchResults.query)] }), searchResults.hasMore && _jsx(Text, { color: "yellow", children: "Host returned only the first 20 global matches; workspace results may be incomplete. Refine your query." }), !searchResults.items.length && _jsx(Text, { children: "No sessions in the returned results" }), _jsx(Picker, { choices: [...searchResults.items.map(item => ({ key: string(item.sessionId),
|
|
342
|
+
label: toolLine(`${item.sessionId} · ${item.snippet}`, width - 2),
|
|
343
|
+
action: () => operate(() => openSearchSession(string(item.sessionId), searchResults.query)),
|
|
344
|
+
})), { key: 'close', label: '← Back', action: () => setSearchResults(undefined) }], enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy })] }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session' }), _jsx(Picker, { choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }, `${state.screen}:${state.workspaceId ?? ''}`)] }) : _jsxs(_Fragment, { children: [state.screen === 'chat' && historyQuery === undefined && !searchResults && _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { children: visible.length ? visible.join('\n') : 'Start a conversation with the host agent.' }), state.transcript.hasMore && _jsx(Text, { dimColor: true, children: "Scroll up or /older to load earlier history" })] }), state.screen === 'chat' && historyQuery !== undefined && _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: [contentSearch ? 'Search · session history' : 'History · loaded records', " \u00B7 Esc close"] }), _jsx(Picker, { choices: [
|
|
345
|
+
...layout.messages.filter(message => (contentSearch ? message.text : `${message.seq} ${message.role} ${message.text}`).toLowerCase().includes(historyQuery.toLowerCase())).map(message => ({
|
|
346
|
+
key: String(message.seq), label: toolLine(`#${message.seq} ${message.role} · ${message.text}`, width - 2),
|
|
347
|
+
action: () => operate(() => jumpHistory(message.seq)),
|
|
348
|
+
})),
|
|
349
|
+
{ key: 'close', label: '← Back to conversation', action: () => setHistoryQuery(undefined) },
|
|
350
|
+
], enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }, `history:${historyQuery}`)] }), pending && _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: question ? 'Question' : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), question?.options && _jsx(Text, { children: array(question.options).map(option => string(object(option).label)).join(' · ') }), _jsx(Text, { dimColor: true, children: question ? 'Type your answer below' : '/allow approves once · /deny rejects' })] })] }), _jsxs(Box, { borderStyle: "round", borderColor: state.online ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "cyan", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, focus: state.online && !state.busy, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Host files \u00B7 \u2191 \u2193 select \u00B7 Tab/Enter insert \u00B7 Esc close" }), !matches ? _jsx(Text, { dimColor: true, children: "Searching\u2026" }) : matches.error ? _jsx(Text, { color: "red", children: matches.error })
|
|
351
|
+
: matches.items.length === 0 ? _jsx(Text, { dimColor: true, children: "No matching host files" })
|
|
352
|
+
: matches.items.slice(Math.max(0, referenceIndex - 5), Math.max(0, referenceIndex - 5) + 6).map((item, index) => _jsxs(Text, { color: index + Math.max(0, referenceIndex - 5) === referenceIndex ? 'cyan' : undefined, children: [index + Math.max(0, referenceIndex - 5) === referenceIndex ? '❯ ' : ' ', item.path, item.kind === 'directory' ? '/' : ''] }, `${item.kind}:${item.path}`))] }), _jsx(Text, { dimColor: true, children: "Enter send \u00B7 Esc cancel \u00B7 Wheel/PgUp/PgDn scroll \u00B7 Ctrl+C stop / exit" }), input.startsWith('/') && !input.includes(' ') && _jsx(Text, { dimColor: true, children: COMMANDS.filter(command => command.startsWith(input)).join(' ') }), help && _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: HELP }), _jsx(Text, { dimColor: true, children: "Editing: Ctrl+A/E start/end \u00B7 Ctrl+K/U kill right/left \u00B7 Ctrl+W kill word \u00B7 Ctrl+Y restore" })] }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(StatusBar, { controller: controller, expanded: statusExpanded, revision: state.version })] });
|
|
353
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Client } from './client.ts';
|
|
2
|
+
/** Fatal authentication failure requiring a new startup token. */
|
|
3
|
+
export declare class AuthenticationRequired extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Private, atomic cookie files, separated by the complete HTTP origin. */
|
|
6
|
+
export declare class CookieStore {
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
constructor(directory?: string);
|
|
9
|
+
/** Read an unexpired cookie, rejecting unsafe permissions or malformed storage. */
|
|
10
|
+
load(origin: string): Promise<string | undefined>;
|
|
11
|
+
/** Atomically save a persistent host cookie with owner-only POSIX permissions. */
|
|
12
|
+
save(origin: string, cookie: string, expiresAt: number): Promise<void>;
|
|
13
|
+
private path;
|
|
14
|
+
}
|
|
15
|
+
/** Reuse a validated cookie first; only authentication rejection permits token fallback. */
|
|
16
|
+
export declare function login(client: Client, token: string | undefined, store: CookieStore): Promise<void>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Origin-scoped cookie persistence; launch tokens are never written to disk. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { HttpError } from "./client.js";
|
|
8
|
+
/** Fatal authentication failure requiring a new startup token. */
|
|
9
|
+
export class AuthenticationRequired extends Error {
|
|
10
|
+
}
|
|
11
|
+
/** Private, atomic cookie files, separated by the complete HTTP origin. */
|
|
12
|
+
export class CookieStore {
|
|
13
|
+
directory;
|
|
14
|
+
constructor(directory = process.env.DSHT_AUTH_DIR
|
|
15
|
+
?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht', 'auth')) {
|
|
16
|
+
this.directory = directory;
|
|
17
|
+
}
|
|
18
|
+
/** Read an unexpired cookie, rejecting unsafe permissions or malformed storage. */
|
|
19
|
+
async load(origin) {
|
|
20
|
+
let handle;
|
|
21
|
+
try {
|
|
22
|
+
handle = await open(this.path(origin), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (isMissing(error))
|
|
26
|
+
return undefined;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const stat = await handle.stat();
|
|
31
|
+
if (!stat.isFile() || process.platform !== 'win32' && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())) {
|
|
32
|
+
throw new Error('Cookie file must be owned by the current user with permissions 0600');
|
|
33
|
+
}
|
|
34
|
+
const raw = await handle.readFile('utf8');
|
|
35
|
+
let saved;
|
|
36
|
+
try {
|
|
37
|
+
saved = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error('Invalid cookie file JSON. Remove the origin cookie file and log in again.');
|
|
41
|
+
}
|
|
42
|
+
if (saved === null || typeof saved !== 'object' || !('version' in saved) || saved.version !== 1
|
|
43
|
+
|| !('origin' in saved) || saved.origin !== origin || !('cookie' in saved) || typeof saved.cookie !== 'string'
|
|
44
|
+
|| !('expiresAt' in saved) || typeof saved.expiresAt !== 'number' || !Number.isSafeInteger(saved.expiresAt)) {
|
|
45
|
+
throw new Error('Invalid saved cookie. Remove the origin cookie file and log in again.');
|
|
46
|
+
}
|
|
47
|
+
return saved.expiresAt > Date.now() ? saved.cookie : undefined;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await handle.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Atomically save a persistent host cookie with owner-only POSIX permissions. */
|
|
54
|
+
async save(origin, cookie, expiresAt) {
|
|
55
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
const stat = await lstat(this.directory);
|
|
57
|
+
if (!stat.isDirectory() || process.platform !== 'win32' && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())) {
|
|
58
|
+
throw new Error('Cookie directory must be owned by the current user with permissions 0700');
|
|
59
|
+
}
|
|
60
|
+
const temporary = join(this.directory, `${randomUUID()}.tmp`);
|
|
61
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
62
|
+
try {
|
|
63
|
+
try {
|
|
64
|
+
await handle.writeFile(JSON.stringify({ version: 1, origin, cookie, expiresAt }) + '\n');
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
await rename(temporary, this.path(origin));
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
try {
|
|
73
|
+
await unlink(temporary);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!isMissing(error))
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
path(origin) {
|
|
82
|
+
return join(this.directory, `${createHash('sha256').update(origin).digest('hex')}.json`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Reuse a validated cookie first; only authentication rejection permits token fallback. */
|
|
86
|
+
export async function login(client, token, store) {
|
|
87
|
+
const cookie = await store.load(client.base.origin);
|
|
88
|
+
if (cookie) {
|
|
89
|
+
client.restoreCookie(cookie);
|
|
90
|
+
try {
|
|
91
|
+
await client.call('session/list', { _request: {} });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!(error instanceof HttpError) || error.status !== 401)
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!token)
|
|
100
|
+
throw new AuthenticationRequired('Login required: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web');
|
|
101
|
+
await client.authenticate(token);
|
|
102
|
+
const session = client.persistentCookie;
|
|
103
|
+
if (session)
|
|
104
|
+
await store.save(client.base.origin, session.cookie, session.expiresAt);
|
|
105
|
+
}
|
|
106
|
+
function isMissing(error) {
|
|
107
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
108
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/** Standalone executable entry; connects to an existing host and never launches Harness. */
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { CostLedger, DEFAULT_PRICES, pricesFrom } from "./cost.js";
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { render } from 'ink';
|
|
11
|
+
import { App } from "./app.js";
|
|
12
|
+
import { sessionLabel } from "./navigation.js";
|
|
13
|
+
import { CookieStore, login } from "./auth.js";
|
|
14
|
+
import { Client } from "./client.js";
|
|
15
|
+
import { Controller } from "./controller.js";
|
|
16
|
+
import { endpoint } from "./endpoint.js";
|
|
17
|
+
import { errorText, safeText, string } from "./wire.js";
|
|
18
|
+
const HELP = `Usage: dsht [options] [list workspaces|list sessions]
|
|
19
|
+
|
|
20
|
+
With no command, choose a workspace and session interactively.
|
|
21
|
+
|
|
22
|
+
--url <url> Host URL, or the dsh web URL with ?token= (DSH_URL)
|
|
23
|
+
--workspace <id> Filter list sessions by workspace
|
|
24
|
+
--session <id> Open a session directly
|
|
25
|
+
--auth-dir <path> Private cookie directory (or DSHT_AUTH_DIR)
|
|
26
|
+
--json Print machine-readable list output
|
|
27
|
+
--help Show this help
|
|
28
|
+
|
|
29
|
+
The default host is http://127.0.0.1:3080.
|
|
30
|
+
First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
|
|
31
|
+
Cookies are saved per server origin and reused on later starts. Tokens are never saved.
|
|
32
|
+
/cost shows session, today and three-day CNY estimates.
|
|
33
|
+
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
34
|
+
Examples:
|
|
35
|
+
npx @itookit/dsht
|
|
36
|
+
dsht list workspaces --json
|
|
37
|
+
dsht list sessions --workspace <id> --json
|
|
38
|
+
`;
|
|
39
|
+
async function main() {
|
|
40
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
41
|
+
url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
|
|
42
|
+
workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
|
|
43
|
+
} });
|
|
44
|
+
if (values.help) {
|
|
45
|
+
process.stdout.write(HELP);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const list = positionals[0] === 'list' && ['workspaces', 'sessions'].includes(positionals[1] ?? '') && positionals.length === 2;
|
|
49
|
+
if (positionals.length && !list)
|
|
50
|
+
throw new Error('Unknown command. Use --help.');
|
|
51
|
+
if (!list && (values.json || values.workspace))
|
|
52
|
+
throw new Error('--json and --workspace apply to list commands');
|
|
53
|
+
if (list && values.session)
|
|
54
|
+
throw new Error('--session applies to interactive mode');
|
|
55
|
+
const { url, token } = endpoint(values.url, process.env.DSH_TOKEN);
|
|
56
|
+
const store = new CookieStore(values['auth-dir']);
|
|
57
|
+
if (list) {
|
|
58
|
+
const client = new Client(url);
|
|
59
|
+
try {
|
|
60
|
+
await login(client, token, store);
|
|
61
|
+
if (positionals[1] === 'workspaces' || values.workspace)
|
|
62
|
+
await client.connect();
|
|
63
|
+
const items = positionals[1] === 'workspaces' ? await client.listWorkspaces() : await client.listSessions(values.workspace);
|
|
64
|
+
if (values.json)
|
|
65
|
+
process.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
|
|
66
|
+
else {
|
|
67
|
+
const lines = items.map(item => positionals[1] === 'workspaces'
|
|
68
|
+
? `${string(item.workspaceId)}\t${string(item.title)}\t${string(item.path)}`
|
|
69
|
+
: `${string(item.sessionId)}\t${sessionLabel(item)}\t${item.running ? 'running' : 'idle'}`);
|
|
70
|
+
process.stdout.write(`${safeText(lines.join('\n'))}${lines.length ? '\n' : ''}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await client.close();
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
79
|
+
throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
|
|
80
|
+
const config = process.env.DSHT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'dsht');
|
|
81
|
+
await mkdir(config, { recursive: true, mode: 0o700 });
|
|
82
|
+
const pricePath = join(config, 'prices.json');
|
|
83
|
+
try {
|
|
84
|
+
await writeFile(pricePath, JSON.stringify(DEFAULT_PRICES, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const prices = pricesFrom(JSON.parse(await readFile(pricePath, 'utf8')));
|
|
91
|
+
const costDirectory = join(process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht'), 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
|
|
92
|
+
const costs = new CostLedger(prices, costDirectory);
|
|
93
|
+
await costs.load();
|
|
94
|
+
const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs);
|
|
95
|
+
const app = render(_jsx(App, { controller: controller }), { exitOnCtrlC: false });
|
|
96
|
+
const terminate = () => app.unmount();
|
|
97
|
+
process.once('SIGTERM', terminate);
|
|
98
|
+
controller.start();
|
|
99
|
+
try {
|
|
100
|
+
await app.waitUntilExit();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
process.off('SIGTERM', terminate);
|
|
104
|
+
await controller.stop();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type Json, type ObjectValue } from './wire.ts';
|
|
2
|
+
/** HTTP failure remains distinct from host business errors. */
|
|
3
|
+
export declare class HttpError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(status: number, endpoint: string);
|
|
6
|
+
}
|
|
7
|
+
/** Host business failure, including the original machine-readable details. */
|
|
8
|
+
export declare class RemoteError extends Error {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly details: Json | undefined;
|
|
11
|
+
constructor(value: unknown);
|
|
12
|
+
}
|
|
13
|
+
/** A logical stream owns its callback registration until cancellation or termination. */
|
|
14
|
+
export interface Subscription {
|
|
15
|
+
cancel(): void;
|
|
16
|
+
}
|
|
17
|
+
interface Listener {
|
|
18
|
+
item(value: Json | undefined): void;
|
|
19
|
+
end(error?: Error): void;
|
|
20
|
+
}
|
|
21
|
+
/** A single authenticated host connection; close before reconnecting or exiting. */
|
|
22
|
+
export declare class Client {
|
|
23
|
+
readonly timeoutMs: number;
|
|
24
|
+
readonly base: URL;
|
|
25
|
+
private cookie;
|
|
26
|
+
private expiresAt;
|
|
27
|
+
private socket;
|
|
28
|
+
private listeners;
|
|
29
|
+
private lifetime;
|
|
30
|
+
constructor(base: string, timeoutMs?: number);
|
|
31
|
+
/** Exchange a startup token only at GET / and retain the server's cookie expiration. */
|
|
32
|
+
authenticate(token: string): Promise<void>;
|
|
33
|
+
/** Restore one origin-scoped cookie read by the caller's credential store. */
|
|
34
|
+
restoreCookie(cookie: string): void;
|
|
35
|
+
/** Session-only cookies are not eligible for persistent storage. */
|
|
36
|
+
get persistentCookie(): {
|
|
37
|
+
cookie: string;
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
} | undefined;
|
|
40
|
+
/** Invoke an exact endpoint once. Mutations are never automatically retried.
|
|
41
|
+
* @param endpoint - Namespace/method endpoint.
|
|
42
|
+
* @param args - Host parameter names and JSON values.
|
|
43
|
+
* @param signal - Optional caller cancellation, combined with client close and timeout.
|
|
44
|
+
* @returns The decoded result value; HTTP, remote, and cancellation errors reject.
|
|
45
|
+
*/
|
|
46
|
+
call(endpoint: string, args?: ObjectValue, signal?: AbortSignal): Promise<Json | undefined>;
|
|
47
|
+
/** Connect the physical mux. A disconnected instance may reconnect with its cookie. */
|
|
48
|
+
connect(): Promise<void>;
|
|
49
|
+
/** Subscribe on the existing mux; each subscription has a fresh stream identity. */
|
|
50
|
+
subscribe(endpoint: string, args: ObjectValue, listener: Listener): Subscription;
|
|
51
|
+
/** List workspaces by consuming and cancelling the authoritative opening baseline. */
|
|
52
|
+
listWorkspaces(): Promise<ObjectValue[]>;
|
|
53
|
+
/** List visible sessions, optionally filtering by the workspace's accounted IDs. */
|
|
54
|
+
listSessions(workspaceId?: string): Promise<ObjectValue[]>;
|
|
55
|
+
/** Close all streams, abort in-flight HTTP, and await the physical socket's closure. */
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
private signal;
|
|
58
|
+
private fail;
|
|
59
|
+
private finish;
|
|
60
|
+
}
|
|
61
|
+
export {};
|