@aryacode/dsh-plugin-arya-computer-use 0.0.0-stage → 1.0.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.
@@ -0,0 +1,299 @@
1
+ // Host-side activity tracking for dsh-arya-computer-use. Derived from
2
+ // dsh-computer-use-win (MIT): the host knows turn boundaries, the stdio
3
+ // adapter only knows individual calls, so the lease lives here. Publishes
4
+ // activity.json (read by TakeoverControl for the overlay/indicator/Esc-kill)
5
+ // and enforces the driver lock: one conversation drives the desktop at a time.
6
+ import { atomicJson } from './takeover.mjs';
7
+
8
+ export function isDesktopTool(name = '') {
9
+ return /^mcp__arya_desktop__(?!tools$)[a-z_]+$/.test(name);
10
+ }
11
+
12
+ export const ACTIVITY_IDLE_MS = 30000;
13
+ // After a turn ends the overlay keeps its last state this long, so the user
14
+ // can read the result without raising the DSH window.
15
+ export const PROGRESS_LINGER_MS = 20000;
16
+ // Bounded history: the most recent steps, older steps keep only the tail of
17
+ // their text, the current step keeps more.
18
+ const MAX_STEPS = 24, MAX_ACTIONS = 6, OLD_STEP_TEXT = 400, CURRENT_TEXT = 1200, INTENT_TEXT = 300;
19
+
20
+ const callArguments = value => {
21
+ if (typeof value === 'string') try { return JSON.parse(value); } catch { return {}; }
22
+ return value && typeof value === 'object' ? value : {};
23
+ };
24
+ const shortName = name => String(name ?? '').replace(/^mcp__arya_desktop__/, '');
25
+ const cleanText = text => text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
26
+ const tail = (text, limit) => text.length > limit ? '…' + text.slice(-(limit - 1)) : text;
27
+
28
+ // Plain-language names for what a call does. They come from the tool name and
29
+ // the shape of the call, never from values: no typed text, URLs, selectors or
30
+ // coordinates.
31
+ const LABELS = {
32
+ snapshot: 'read window content', find: 'find elements', get: 'read element', is: 'check element state',
33
+ click: 'click element', set_value: 'set value', type: 'type text', press: 'press keys', scroll: 'scroll',
34
+ list_windows: 'list windows', launch: 'launch app', wait: 'wait', screenshot: 'take screenshot',
35
+ ocr: 'read screen text',
36
+ // DSH's own tools that often run inside a computer-use turn.
37
+ read_image: 'view screenshot', web_search: 'search web', web_fetch: 'read web page',
38
+ };
39
+ export function actionLabel(name = '', args = {}) {
40
+ const tool = shortName(name);
41
+ if (tool === 'batch') {
42
+ const steps = Array.isArray(args.steps) ? args.steps : [];
43
+ const kinds = [...new Set(steps.map(step => actionLabel(`mcp__arya_desktop__${step?.action}`, callArguments(step?.arguments))).filter(Boolean))];
44
+ return `run ${steps.length} actions${kinds.length ? ': ' + kinds.slice(0, 2).join(', ') + (kinds.length > 2 ? '…' : '') : ''}`;
45
+ }
46
+ return LABELS[tool] ?? '';
47
+ }
48
+
49
+ // DSH's stop button aborts a call with a plain object ({kind:'user'}), and its
50
+ // MCP client passes that object on as the error, so the agent would read
51
+ // "Error: [object Object]". Say what happened instead.
52
+ export function readableAbort(exec, result) {
53
+ if (!result?.isError || !exec.signal?.aborted || result.error?.message !== '[object Object]') return result;
54
+ const message = exec.signal.reason?.kind === 'user'
55
+ ? 'COMPUTER_USE_STOPPED_IN_DSH: The user stopped this call with the DSH stop button. Do not retry it in this turn. A new user turn, including a request to continue, can use the desktop again.'
56
+ : 'COMPUTER_USE_CANCELLED: DSH cancelled this call before it finished.';
57
+ return { ...result, content: [{ type: 'text', text: 'Error: ' + message }], error: { ...result.error, message } };
58
+ }
59
+
60
+ const newProgress = (startedAt = Date.now()) => ({ status: 'waiting', step: 0, tool: '', action: '', reasoning: '', reply: '', steps: [], startedAt });
61
+ // One entry per model step: the actions it took and the text DSH showed for it.
62
+ function stepEntry(progress, step) {
63
+ let entry = progress.steps.at(-1);
64
+ if (entry?.step === step) return entry;
65
+ for (const old of progress.steps) { old.reasoning = tail(old.reasoning, OLD_STEP_TEXT); old.reply = tail(old.reply, OLD_STEP_TEXT); }
66
+ entry = { step, actions: [], reasoning: '', reply: '' };
67
+ progress.steps.push(entry);
68
+ if (progress.steps.length > MAX_STEPS) progress.steps.splice(0, progress.steps.length - MAX_STEPS);
69
+ return entry;
70
+ }
71
+ function addAction(progress, label) {
72
+ const entry = stepEntry(progress, progress.step || 1), action = { label, state: 'running' };
73
+ entry.actions.push(action);
74
+ if (entry.actions.length > MAX_ACTIONS) entry.actions.splice(0, entry.actions.length - MAX_ACTIONS);
75
+ progress.action = label;
76
+ return action;
77
+ }
78
+
79
+ export function installActivity(ctx, file, { idleMs = ACTIVITY_IDLE_MS, lingerMs = PROGRESS_LINGER_MS, getSettings = () => ({}) } = {}) {
80
+ let owner, lingering, closed = false, needsPublish = true;
81
+ // A turn's text that arrived before its first computer-use call (usually the
82
+ // model deciding to use the desktop). Kept in memory only, per running turn,
83
+ // and shown only if that turn starts using the desktop.
84
+ const early = new Map();
85
+ // The start of each step's reply, in memory only. While a reply streams the
86
+ // file keeps its last characters so the overlay follows the end; when the
87
+ // turn finishes, a final reply longer than that is shown from its start.
88
+ const replyStarts = new WeakMap();
89
+ const expireIdle = () => { if (owner && owner.pending === 0 && Date.now() - owner.lastActive >= idleMs) owner = undefined; };
90
+ const publish = () => {
91
+ needsPublish = true;
92
+ expireIdle();
93
+ if (lingering && (owner || Date.now() >= lingering.until)) lingering = undefined;
94
+ const shown = owner ?? lingering?.owner;
95
+ // Only the overlay reads this text. While the overlay is off the text
96
+ // stays in memory, so turning it on mid-turn still shows the whole turn.
97
+ atomicJson(file, {
98
+ active: !!owner, sessionId: owner?.id ?? null, turn: owner?.turn ?? null, indicator: !!owner,
99
+ expiresAt: owner ? Date.now() + 5000 : 0,
100
+ ...(shown && getSettings().progressOverlayEnabled !== false ? { progress: shown.progress } : {}),
101
+ ...(!owner && lingering ? { lingerUntil: lingering.until } : {}),
102
+ });
103
+ needsPublish = false;
104
+ };
105
+ publish();
106
+ const startOwner = (session, turn) => {
107
+ const seed = early.get(session.id);
108
+ early.delete(session.id);
109
+ const progress = newProgress(seed?.turn === turn ? seed.startedAt : Date.now());
110
+ if (seed?.turn === turn && seed.step) {
111
+ progress.step = seed.step;
112
+ const entry = stepEntry(progress, seed.step);
113
+ entry.reasoning = progress.reasoning = seed.reasoning;
114
+ entry.reply = progress.reply = seed.reply;
115
+ if (seed.reasoning) { progress.intent = seed.reasoning.slice(0, INTENT_TEXT); progress.intentStep = seed.step; }
116
+ }
117
+ return progress;
118
+ };
119
+ ctx.on('tools/execute', async (exec, next) => {
120
+ const session = exec.agent?.session;
121
+ const turn = exec.agent?.phase?.kind === 'running' ? exec.agent.phase.turn : undefined;
122
+ if (!isDesktopTool(exec.name)) {
123
+ // Another DSH tool inside a computer-use turn gets a line in the step
124
+ // history when it has a plain-language name; nothing else changes.
125
+ const label = owner && session?.id === owner.id && turn === owner.turn ? actionLabel(exec.name, callArguments(exec.arguments)) : '';
126
+ if (!label) return next();
127
+ const current = owner, progress = current.progress, action = addAction(progress, label);
128
+ progress.status = 'acting';
129
+ needsPublish = true;
130
+ try { const result = await next(); action.state = result?.isError ? 'error' : 'done'; return result; }
131
+ catch (error) { action.state = 'error'; throw error; }
132
+ finally {
133
+ current.lastActive = Date.now();
134
+ if (current.pending === 0 && progress.action === label) { progress.status = 'waiting'; progress.action = ''; }
135
+ needsPublish = true;
136
+ }
137
+ }
138
+ if (exec.signal?.aborted) return readableAbort(exec, await next());
139
+ if (session && turn !== undefined) {
140
+ expireIdle();
141
+ if (owner && owner.id !== session.id) {
142
+ // Driver lock: one conversation drives the desktop at a time.
143
+ if (getSettings().driverLockEnabled !== false) {
144
+ throw new Error('COMPUTER_USE_BUSY: Another conversation is driving the desktop. Wait for that operation series to finish, ask the user to stop it (Esc), or turn off the driver lock in Computer Use settings before retrying.');
145
+ }
146
+ owner = undefined; // driver lock off: the newest conversation takes over
147
+ }
148
+ if (!owner) {
149
+ owner = { id: session.id, session, turn, pending: 0, lastActive: Date.now(), desktop: false, progress: startOwner(session, turn) };
150
+ lingering = undefined;
151
+ }
152
+ const current = owner;
153
+ if (current.turn !== turn) { current.turn = turn; current.desktop = false; current.progress = startOwner(session, turn); }
154
+ const args = callArguments(exec.arguments), tool = shortName(exec.name);
155
+ current.desktop = true;
156
+ const action = addAction(current.progress, actionLabel(tool, args) || tool);
157
+ current.pending++;
158
+ current.progress.status = 'acting';
159
+ current.progress.tool = tool;
160
+ publish();
161
+ try {
162
+ const result = readableAbort(exec, await next());
163
+ action.state = result?.isError ? 'error' : 'done';
164
+ current.progress.lastAction = { tool, status: result?.isError ? 'error' : 'returned' };
165
+ return result;
166
+ } catch (error) {
167
+ action.state = 'error';
168
+ current.progress.lastAction = { tool, status: 'error' };
169
+ throw error;
170
+ } finally {
171
+ current.pending--;
172
+ current.lastActive = Date.now();
173
+ if (current.pending === 0) { current.progress.status = 'waiting'; current.progress.tool = ''; current.progress.action = ''; }
174
+ if (owner === current) publish();
175
+ }
176
+ }
177
+ return readableAbort(exec, await next());
178
+ }, { global: true });
179
+ ctx.on('session/event', (session, event) => {
180
+ const turn = event.data?.turn;
181
+ if (event.type === 'turn/end') {
182
+ early.delete(session.id);
183
+ if (owner?.id === session.id && owner.turn === turn) {
184
+ const kind = event.data.reason?.kind, progress = owner.progress;
185
+ progress.status = kind === 'aborted' ? 'stopped' : kind === 'error' ? 'failed' : 'done';
186
+ progress.endedAt = Date.now();
187
+ progress.tool = '';
188
+ progress.action = '';
189
+ const last = progress.steps.at(-1), start = last && replyStarts.get(last);
190
+ if (start && start.length > CURRENT_TEXT) { last.reply = start.text.slice(0, CURRENT_TEXT - 1) + '…'; if (progress.step === last.step) progress.reply = last.reply; }
191
+ lingering = lingerMs > 0 ? { owner, until: Date.now() + lingerMs } : undefined;
192
+ owner = undefined;
193
+ publish();
194
+ }
195
+ return;
196
+ }
197
+ const step = Number.isSafeInteger(event.data?.step) ? event.data.step : undefined;
198
+ if (event.type === 'step/start') stepStarted(session.id, turn, step);
199
+ // Older hosts recorded streamed text as assistant/chunk session events.
200
+ const chunk = event.type === 'assistant/chunk' ? event.data.chunk : undefined;
201
+ if (chunk) acceptText(session.id, turn, step, chunk);
202
+ }, { global: true });
203
+ // DSH streams the model's output as agent/assistant-stream frames: a start
204
+ // frame names the turn and step of one model attempt, its chunk frames carry
205
+ // the text, and an abandoned attempt is retried from the start.
206
+ const attempts = new Map(), retried = new Set();
207
+ ctx.on('agent/assistant-stream', ({ agent, frame } = {}) => {
208
+ const sessionId = agent?.session?.id;
209
+ if (!sessionId || !frame) return;
210
+ if (frame.type === 'start') {
211
+ if (!Number.isSafeInteger(frame.turn)) return;
212
+ const key = JSON.stringify([sessionId, frame.turn, frame.step]);
213
+ attempts.set(frame.attemptId, { sessionId, turn: frame.turn, step: Number.isSafeInteger(frame.step) ? frame.step : undefined, key });
214
+ while (attempts.size > 32) attempts.delete(attempts.keys().next().value);
215
+ if (retried.delete(key)) clearStepText(sessionId, frame.turn, frame.step);
216
+ stepStarted(sessionId, frame.turn, frame.step);
217
+ return;
218
+ }
219
+ const attempt = attempts.get(frame.attemptId);
220
+ if (!attempt) return;
221
+ if (frame.type === 'end') {
222
+ attempts.delete(frame.attemptId);
223
+ if (frame.outcome?.kind === 'abandoned') { retried.add(attempt.key); while (retried.size > 32) retried.delete(retried.values().next().value); }
224
+ return;
225
+ }
226
+ if (frame.type === 'chunk') acceptText(sessionId, attempt.turn, attempt.step, frame.chunk);
227
+ }, { global: true });
228
+ function stepStarted(sessionId, turn, step) {
229
+ if (turn === undefined) return;
230
+ if (owner?.id === sessionId && owner.turn === turn) {
231
+ const progress = owner.progress;
232
+ if (step === undefined || step === progress.step && progress.steps.at(-1)?.step === step) return;
233
+ progress.step = step;
234
+ stepEntry(progress, step);
235
+ progress.reasoning = '';
236
+ progress.reply = '';
237
+ progress.status = 'waiting';
238
+ needsPublish = true;
239
+ return;
240
+ }
241
+ const seed = early.get(sessionId);
242
+ if (seed?.turn === turn && seed.step === step) return;
243
+ early.set(sessionId, { turn, step: step ?? 0, reasoning: '', reply: '', startedAt: seed?.turn === turn ? seed.startedAt : Date.now() });
244
+ while (early.size > 8) early.delete(early.keys().next().value);
245
+ }
246
+ function clearStepText(sessionId, turn, step) {
247
+ if (owner?.id === sessionId && owner.turn === turn) {
248
+ const entry = owner.progress.steps.find(item => item.step === step);
249
+ if (entry) { entry.reasoning = ''; entry.reply = ''; replyStarts.delete(entry); }
250
+ if (owner.progress.step === step) { owner.progress.reasoning = ''; owner.progress.reply = ''; }
251
+ needsPublish = true;
252
+ } else {
253
+ const seed = early.get(sessionId);
254
+ if (seed?.turn === turn && seed.step === step) { seed.reasoning = ''; seed.reply = ''; }
255
+ }
256
+ }
257
+ // Only text already emitted by the host is displayed; never synthesize
258
+ // reasoning or copy tool arguments, user prompts or another session.
259
+ function acceptText(sessionId, turn, step, chunk) {
260
+ const field = chunk?.type === 'reasoning-delta' ? 'reasoning' : chunk?.type === 'text-delta' ? 'reply' : null;
261
+ const text = field && typeof chunk.text === 'string' ? cleanText(chunk.text) : '';
262
+ if (!text || turn === undefined) return;
263
+ if (!owner || owner.id !== sessionId || owner.turn !== turn) {
264
+ const seed = early.get(sessionId);
265
+ if (seed?.turn === turn && (step === undefined || seed.step === step)) seed[field] = (seed[field] + text).slice(-CURRENT_TEXT);
266
+ return;
267
+ }
268
+ const progress = owner.progress;
269
+ owner.lastActive = Date.now();
270
+ // The turn's first reasoning usually restates the task; the summary needs it.
271
+ if (field === 'reasoning') {
272
+ const at = step ?? progress.step;
273
+ if (progress.intentStep === undefined) progress.intentStep = at;
274
+ if (progress.intentStep === at && (progress.intent ?? '').length < INTENT_TEXT) progress.intent = ((progress.intent ?? '') + text).slice(0, INTENT_TEXT);
275
+ }
276
+ if (step !== undefined && step !== progress.step) { progress.step = step; progress.reasoning = ''; progress.reply = ''; }
277
+ const entry = stepEntry(progress, progress.step || 1);
278
+ progress[field] = (progress[field] + text).slice(-CURRENT_TEXT);
279
+ entry[field] = (entry[field] + text).slice(-CURRENT_TEXT);
280
+ if (field === 'reply') {
281
+ const start = replyStarts.get(entry) ?? { text: '', length: 0 };
282
+ if (start.text.length < CURRENT_TEXT) start.text = (start.text + text).slice(0, CURRENT_TEXT);
283
+ start.length += text.length;
284
+ replyStarts.set(entry, start);
285
+ }
286
+ progress.status = field === 'reasoning' ? 'thinking' : 'replying';
287
+ needsPublish = true;
288
+ }
289
+ ctx.effect(() => {
290
+ // One more write when the linger time is over takes the finished text out of the file.
291
+ const timer = setInterval(() => {
292
+ if ((owner || needsPublish || (lingering && Date.now() >= lingering.until)) && !closed) {
293
+ try { publish(); } catch (error) { ctx.logger.error(error); }
294
+ }
295
+ }, 1000);
296
+ timer.unref();
297
+ return () => { closed = true; clearInterval(timer); owner = undefined; lingering = undefined; early.clear(); publish(); };
298
+ });
299
+ }