copilot-liku-cli 0.0.8 → 0.0.10

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/QUICKSTART.md CHANGED
@@ -60,6 +60,22 @@ npm run test:ui
60
60
  This order gives clearer pass/fail signals by validating runtime health first,
61
61
  then shortcut routing, then module-level UI automation.
62
62
 
63
+ ### Targeting sanity check
64
+
65
+ Before running keyboard-driven automation (especially browser tab operations), verify what Liku considers the active window:
66
+
67
+ ```bash
68
+ liku doctor
69
+ ```
70
+
71
+ This prints the resolved package root/version (to confirm local vs global) and the current active window (title/process).
72
+
73
+ For deterministic, machine-readable output (recommended for smaller models / automation), use:
74
+
75
+ ```bash
76
+ liku doctor --json
77
+ ```
78
+
63
79
  `smoke:shortcuts` intentionally validates chat visibility via direct in-app
64
80
  toggle and validates keyboard routing on overlay with target gating.
65
81
 
@@ -138,6 +154,15 @@ Right-click the tray icon to see:
138
154
 
139
155
  ## Common Tasks
140
156
 
157
+ ### Browser actions (Edge/Chrome)
158
+
159
+ When automating browsers, be explicit about **targeting**:
160
+ 1. Ensure the correct browser window is active (bring it to front / focus it)
161
+ 2. Ensure the correct tab is active (click the tab title, or use \`ctrl+1..9\`)
162
+ 3. Then perform the action (e.g., close tab with \`ctrl+w\`)
163
+
164
+ If you skip steps 1–2 and the overlay/chat has focus, keyboard shortcuts may close the overlay instead of affecting the browser.
165
+
141
166
  ### Selecting a Screen Element
142
167
  ```
143
168
  1. Press Ctrl+Alt+Space to open chat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-liku-cli",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "GitHub Copilot CLI with headless agent + ultra-thin overlay architecture",
5
5
  "main": "src/main/index.js",
6
6
  "bin": {
@@ -0,0 +1,509 @@
1
+ /**
2
+ * doctor command - Minimal diagnostics for targeting reliability
3
+ * @module cli/commands/doctor
4
+ */
5
+
6
+ const path = require('path');
7
+ const { success, error, info, highlight, dim } = require('../util/output');
8
+
9
+ const PROJECT_ROOT = path.resolve(__dirname, '../../..');
10
+ const UI_MODULE = path.resolve(__dirname, '../../main/ui-automation');
11
+
12
+ const DOCTOR_SCHEMA_VERSION = 'doctor.v1';
13
+
14
+ function safeJsonStringify(value) {
15
+ try {
16
+ return JSON.stringify(value, null, 2);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ async function withConsoleSilenced(enabled, fn) {
23
+ if (!enabled) {
24
+ return fn();
25
+ }
26
+
27
+ const original = {
28
+ log: console.log,
29
+ info: console.info,
30
+ warn: console.warn,
31
+ error: console.error,
32
+ };
33
+
34
+ console.log = () => {};
35
+ console.info = () => {};
36
+ console.warn = () => {};
37
+ console.error = () => {};
38
+
39
+ try {
40
+ return await fn();
41
+ } finally {
42
+ console.log = original.log;
43
+ console.info = original.info;
44
+ console.warn = original.warn;
45
+ console.error = original.error;
46
+ }
47
+ }
48
+
49
+ function normalizeText(text) {
50
+ return String(text || '').trim();
51
+ }
52
+
53
+ function normalizeForMatch(text) {
54
+ return normalizeText(text).toLowerCase();
55
+ }
56
+
57
+ function normalizeForLooseMatch(text) {
58
+ return normalizeForMatch(text)
59
+ .replace(/[^a-z0-9]+/g, ' ')
60
+ .replace(/\s+/g, ' ')
61
+ .trim();
62
+ }
63
+
64
+ function includesCI(haystack, needle) {
65
+ if (!haystack || !needle) return false;
66
+ // Loose match to tolerate punctuation differences (e.g., "Microsoft? Edge Beta")
67
+ return normalizeForLooseMatch(haystack).includes(normalizeForLooseMatch(needle));
68
+ }
69
+
70
+ function extractQuotedStrings(text) {
71
+ const out = [];
72
+ const str = normalizeText(text);
73
+ const re = /"([^"]+)"|'([^']+)'/g;
74
+ let m;
75
+ while ((m = re.exec(str)) !== null) {
76
+ const val = m[1] || m[2];
77
+ if (val) out.push(val);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function parseRequestHints(requestText) {
83
+ const text = normalizeText(requestText);
84
+ const lower = normalizeForMatch(text);
85
+
86
+ // Extract common patterns
87
+ const tabTitleMatch = /\btab\s+(?:titled|named|called)\s+(?:"([^"]+)"|'([^']+)'|([^,.;\n\r]+))/i.exec(text);
88
+ const tabTitle = tabTitleMatch ? normalizeText(tabTitleMatch[1] || tabTitleMatch[2] || tabTitleMatch[3]) : null;
89
+
90
+ const inWindowMatch = /\b(?:in|within)\s+([^\n\r]+?)\s+window\b/i.exec(text);
91
+ const windowHint = inWindowMatch ? normalizeText(inWindowMatch[1]) : null;
92
+
93
+ // Heuristic: infer app family
94
+ const appHints = {
95
+ isBrowser: /\b(edge|chrome|browser|msedge)\b/i.test(text),
96
+ isEditor: /\b(vs\s*code|visual\s*studio\s*code|code\s*-\s*insiders|editor)\b/i.test(text),
97
+ isTerminal: /\b(terminal|powershell|cmd\.exe|command\s+prompt|windows\s+terminal)\b/i.test(text),
98
+ isExplorer: /\b(file\s+explorer|explorer\.exe)\b/i.test(text),
99
+ };
100
+
101
+ // Infer intent
102
+ const intent = (() => {
103
+ if (/\bclose\b/.test(lower) && /\btab\b/.test(lower)) return 'close_tab';
104
+ if (/\bclose\b/.test(lower) && /\bwindow\b/.test(lower)) return 'close_window';
105
+ if (/\bclick\b/.test(lower)) return 'click';
106
+ if (/\btype\b/.test(lower) || /\benter\b/.test(lower)) return 'type';
107
+ if (/\bscroll\b/.test(lower)) return 'scroll';
108
+ if (/\bdrag\b/.test(lower)) return 'drag';
109
+ if (/\bfind\b/.test(lower) || /\blocate\b/.test(lower)) return 'find';
110
+ if (/\bfocus\b/.test(lower) || /\bactivate\b/.test(lower) || /\bbring\b/.test(lower)) return 'focus';
111
+ return 'unknown';
112
+ })();
113
+
114
+ const quoted = extractQuotedStrings(text);
115
+
116
+ // Potential element text is often quoted, but avoid using the tab title as element text.
117
+ const elementTextCandidates = quoted.filter(q => q && q !== tabTitle);
118
+
119
+ return {
120
+ raw: text,
121
+ intent,
122
+ windowHint,
123
+ tabTitle,
124
+ appHints,
125
+ elementTextCandidates,
126
+ };
127
+ }
128
+
129
+ function scoreWindowCandidate(win, hints) {
130
+ let score = 0;
131
+ const reasons = [];
132
+
133
+ const title = win?.title || '';
134
+ const proc = win?.processName || '';
135
+
136
+ if (hints.windowHint && includesCI(title, hints.windowHint)) {
137
+ score += 60;
138
+ reasons.push('title matches windowHint');
139
+ }
140
+
141
+ if (hints.appHints?.isBrowser && (includesCI(proc, 'msedge') || includesCI(title, 'edge') || includesCI(proc, 'chrome') || includesCI(title, 'chrome'))) {
142
+ score += 35;
143
+ reasons.push('looks like browser');
144
+ }
145
+ if (hints.appHints?.isEditor && (includesCI(title, 'visual studio code') || includesCI(title, 'code - insiders') || includesCI(proc, 'Code') || includesCI(proc, 'Code - Insiders'))) {
146
+ score += 35;
147
+ reasons.push('looks like editor');
148
+ }
149
+ if (hints.appHints?.isTerminal && (includesCI(title, 'terminal') || includesCI(proc, 'WindowsTerminal') || includesCI(proc, 'pwsh') || includesCI(proc, 'cmd'))) {
150
+ score += 30;
151
+ reasons.push('looks like terminal');
152
+ }
153
+ if (hints.appHints?.isExplorer && (includesCI(proc, 'explorer') || includesCI(title, 'file explorer'))) {
154
+ score += 30;
155
+ reasons.push('looks like explorer');
156
+ }
157
+
158
+ // Prefer non-empty titled windows
159
+ if (normalizeText(title).length > 0) {
160
+ score += 3;
161
+ }
162
+
163
+ return { score, reasons };
164
+ }
165
+
166
+ function buildSuggestedPlan(hints, activeWindow, rankedCandidates) {
167
+ const top = rankedCandidates?.[0]?.window || null;
168
+ const target = top || activeWindow || null;
169
+ const plan = [];
170
+
171
+ const targetTitleForFilter = target?.title ? String(target.title) : null;
172
+
173
+ const targetSelector = (() => {
174
+ if (!target) return null;
175
+ if (typeof target.hwnd === 'number' && Number.isFinite(target.hwnd)) {
176
+ return { by: 'hwnd', value: target.hwnd };
177
+ }
178
+ if (target.title) {
179
+ return { by: 'title', value: target.title };
180
+ }
181
+ return null;
182
+ })();
183
+
184
+ // State machine-ish scaffold. Keep it deterministic and CLI-driven.
185
+ plan.push({
186
+ state: 'VERIFY_ACTIVE_WINDOW',
187
+ goal: 'Confirm which window will receive input',
188
+ command: 'liku window --active',
189
+ verification: 'Active window title/process match the intended target',
190
+ });
191
+
192
+ if (targetSelector && hints.intent !== 'unknown') {
193
+ const frontCmd = targetSelector.by === 'hwnd'
194
+ ? `liku window --front --hwnd ${targetSelector.value}`
195
+ : `liku window --front "${String(targetSelector.value).replace(/"/g, '\\"')}"`;
196
+
197
+ plan.unshift({
198
+ state: 'FOCUS_TARGET_WINDOW',
199
+ goal: 'Bring the intended target window to the foreground',
200
+ command: frontCmd,
201
+ verification: 'Window is foreground and becomes active',
202
+ });
203
+ }
204
+
205
+ // Tab targeting for browsers is always a separate step.
206
+ if (hints.intent === 'close_tab' && hints.tabTitle) {
207
+ const windowFilter = targetTitleForFilter ? ` --window "${targetTitleForFilter.replace(/"/g, '\\"')}"` : '';
208
+ plan.push({
209
+ state: 'ACTIVATE_TARGET_TAB',
210
+ goal: `Make the tab active: "${hints.tabTitle}"`,
211
+ command: `liku click "${String(hints.tabTitle).replace(/"/g, '\\"')}" --type TabItem${windowFilter}`,
212
+ verification: 'The tab becomes active (visually highlighted)',
213
+ notes: 'If UIA cannot see browser tabs, fall back to ctrl+1..9 or ctrl+tab cycling with waits.',
214
+ });
215
+ plan.push({
216
+ state: 'EXECUTE_ACTION',
217
+ goal: 'Close the active tab',
218
+ command: 'liku keys ctrl+w',
219
+ verification: 'Tab disappears; previous tab becomes active',
220
+ });
221
+ return { target, plan };
222
+ }
223
+
224
+ if (hints.intent === 'close_window') {
225
+ plan.push({
226
+ state: 'EXECUTE_ACTION',
227
+ goal: 'Close the active window',
228
+ command: 'liku keys alt+f4',
229
+ verification: 'Window closes and focus changes',
230
+ notes: 'Prefer alt+f4 for closing windows; ctrl+shift+w is app-specific and can close the wrong thing.',
231
+ });
232
+ return { target, plan };
233
+ }
234
+
235
+ if (hints.intent === 'click') {
236
+ const elementText = hints.elementTextCandidates?.[0] || null;
237
+ if (elementText) {
238
+ const windowFilter = targetTitleForFilter ? ` --window "${targetTitleForFilter.replace(/"/g, '\\"')}"` : '';
239
+ plan.push({
240
+ state: 'EXECUTE_ACTION',
241
+ goal: `Click element: "${elementText}"`,
242
+ command: `liku click "${String(elementText).replace(/"/g, '\\"')}"${windowFilter}`,
243
+ verification: 'Expected UI response occurs (button press, navigation, etc.)',
244
+ });
245
+ }
246
+ return { target, plan };
247
+ }
248
+
249
+ // Generic fallback: ensure focus + suggest next step.
250
+ plan.push({
251
+ state: 'NEXT',
252
+ goal: 'If the target is not correct, refine the window hint and retry',
253
+ command: 'liku window # list windows',
254
+ verification: 'You can identify the intended window title/process',
255
+ });
256
+
257
+ return { target, plan };
258
+ }
259
+
260
+ function mermaidForPlan(plan) {
261
+ if (!Array.isArray(plan) || plan.length === 0) return null;
262
+ const ids = plan.map(p => p.state);
263
+ const edges = [];
264
+ for (let i = 0; i < ids.length - 1; i++) {
265
+ edges.push(`${ids[i]} --> ${ids[i + 1]}`);
266
+ }
267
+ return `stateDiagram-v2\n ${edges.join('\n ')}`;
268
+ }
269
+
270
+ function buildChecks({ uiaError, activeWindow, windows, requestText, requestHints, requestAnalysis }) {
271
+ const checks = [];
272
+ const push = (id, status, message, details = null) => {
273
+ checks.push({ id, status, message, details });
274
+ };
275
+
276
+ push(
277
+ 'uia.available',
278
+ uiaError ? 'fail' : 'pass',
279
+ uiaError ? 'UI Automation unavailable or errored' : 'UI Automation available',
280
+ uiaError ? { error: uiaError } : null
281
+ );
282
+
283
+ push(
284
+ 'ui.activeWindow.present',
285
+ activeWindow ? 'pass' : 'warn',
286
+ activeWindow ? 'Active window detected' : 'Active window missing',
287
+ activeWindow ? { title: activeWindow.title, processName: activeWindow.processName, hwnd: activeWindow.hwnd } : null
288
+ );
289
+
290
+ push(
291
+ 'ui.windows.enumerated',
292
+ Array.isArray(windows) && windows.length > 0 ? 'pass' : 'warn',
293
+ Array.isArray(windows) && windows.length > 0 ? `Enumerated ${windows.length} windows` : 'No windows enumerated',
294
+ Array.isArray(windows) ? { count: windows.length } : { count: 0 }
295
+ );
296
+
297
+ if (requestText) {
298
+ push(
299
+ 'request.parsed',
300
+ requestHints ? 'pass' : 'fail',
301
+ requestHints ? 'Request parsed into hints' : 'Request parsing failed',
302
+ requestHints || null
303
+ );
304
+ push(
305
+ 'request.plan.generated',
306
+ requestAnalysis?.plan?.length ? 'pass' : 'warn',
307
+ requestAnalysis?.plan?.length ? `Generated ${requestAnalysis.plan.length} plan steps` : 'No plan steps generated',
308
+ requestAnalysis?.plan?.length ? { steps: requestAnalysis.plan.map(s => s.state) } : null
309
+ );
310
+ }
311
+
312
+ return checks;
313
+ }
314
+
315
+ function summarizeChecks(checks) {
316
+ const summary = { pass: 0, warn: 0, fail: 0 };
317
+ for (const c of checks) {
318
+ if (c.status === 'pass') summary.pass += 1;
319
+ else if (c.status === 'warn') summary.warn += 1;
320
+ else if (c.status === 'fail') summary.fail += 1;
321
+ }
322
+ return summary;
323
+ }
324
+
325
+ async function run(args, options) {
326
+ // Load package metadata from the resolved project root (this is the key signal
327
+ // for "am I running the local install or some other copy?")
328
+ let pkg;
329
+ try {
330
+ pkg = require(path.join(PROJECT_ROOT, 'package.json'));
331
+ } catch (e) {
332
+ if (!options.quiet) {
333
+ error(`Failed to load package.json from ${PROJECT_ROOT}: ${e.message}`);
334
+ }
335
+ return { success: false, error: 'Could not load package metadata', projectRoot: PROJECT_ROOT };
336
+ }
337
+
338
+ const generatedAt = new Date().toISOString();
339
+
340
+ const envInfo = {
341
+ name: pkg.name,
342
+ version: pkg.version,
343
+ projectRoot: PROJECT_ROOT,
344
+ cwd: process.cwd(),
345
+ node: process.version,
346
+ platform: process.platform,
347
+ arch: process.arch,
348
+ execPath: process.execPath,
349
+ };
350
+
351
+ const requestText = args.length > 0 ? args.join(' ') : null;
352
+ const requestHints = requestText ? parseRequestHints(requestText) : null;
353
+
354
+ // UIA / active window + other state
355
+ let activeWindow = null;
356
+ let windows = [];
357
+ let mouse = null;
358
+ let uiaError = null;
359
+ await withConsoleSilenced(Boolean(options.json), async () => {
360
+ try {
361
+ // Lazy load so doctor still works even if UIA deps are missing
362
+ // (we'll just report that in output)
363
+ // eslint-disable-next-line global-require, import/no-dynamic-require
364
+ const ui = require(UI_MODULE);
365
+ activeWindow = await ui.getActiveWindow();
366
+ mouse = await ui.getMousePosition();
367
+
368
+ // Keep window lists bounded by default.
369
+ const maxWindows = options.all ? Number.MAX_SAFE_INTEGER : (options.windows ? parseInt(options.windows, 10) : 15);
370
+ const allWindows = await ui.findWindows({});
371
+ windows = Array.isArray(allWindows) ? allWindows.slice(0, maxWindows) : [];
372
+
373
+ if (!activeWindow) {
374
+ uiaError = 'No active window detected';
375
+ }
376
+ } catch (e) {
377
+ uiaError = e.message;
378
+ }
379
+ });
380
+
381
+ // Candidate targeting analysis (optional)
382
+ let requestAnalysis = null;
383
+ if (requestHints) {
384
+ const candidates = (Array.isArray(windows) ? windows : []).map(w => {
385
+ const { score, reasons } = scoreWindowCandidate(w, requestHints);
386
+ return { score, reasons, window: w };
387
+ }).sort((a, b) => b.score - a.score);
388
+
389
+ const { target, plan } = buildSuggestedPlan(requestHints, activeWindow, candidates);
390
+ requestAnalysis = {
391
+ request: requestHints,
392
+ target,
393
+ candidates: candidates.slice(0, 8).map(c => ({ score: c.score, reasons: c.reasons, window: c.window })),
394
+ plan,
395
+ mermaid: options.flow ? mermaidForPlan(plan) : null,
396
+ };
397
+ }
398
+
399
+ const checks = buildChecks({ uiaError, activeWindow, windows, requestText, requestHints, requestAnalysis });
400
+ const checksSummary = summarizeChecks(checks);
401
+ const ok = checksSummary.fail === 0;
402
+
403
+ const report = {
404
+ schemaVersion: DOCTOR_SCHEMA_VERSION,
405
+ generatedAt,
406
+ ok,
407
+ checks,
408
+ checksSummary,
409
+ env: envInfo,
410
+ request: requestText ? { text: requestText, hints: requestHints } : null,
411
+ uiState: {
412
+ activeWindow,
413
+ windows,
414
+ mouse,
415
+ uiaError: uiaError || null,
416
+ },
417
+ targeting: requestAnalysis ? {
418
+ selectedWindow: requestAnalysis.target || null,
419
+ candidates: requestAnalysis.candidates || [],
420
+ } : null,
421
+ plan: requestAnalysis ? {
422
+ steps: requestAnalysis.plan || [],
423
+ mermaid: requestAnalysis.mermaid || null,
424
+ } : null,
425
+ next: {
426
+ commands: (
427
+ requestAnalysis?.plan?.length
428
+ ? requestAnalysis.plan.map(s => s.command).filter(Boolean)
429
+ : ['liku window --active', 'liku window']
430
+ ),
431
+ },
432
+ };
433
+
434
+ if (options.json) {
435
+ // Caller wants machine-readable output
436
+ return report;
437
+ }
438
+
439
+ if (!options.quiet) {
440
+ console.log(`\n${highlight('Liku Diagnostics (doctor)')}\n`);
441
+
442
+ console.log(`${highlight('Package:')} ${envInfo.name} v${envInfo.version}`);
443
+ console.log(`${highlight('Resolved root:')} ${envInfo.projectRoot}`);
444
+ console.log(`${highlight('Node:')} ${envInfo.node} (${envInfo.platform}/${envInfo.arch})`);
445
+ console.log(`${highlight('CWD:')} ${envInfo.cwd}`);
446
+
447
+ console.log(`${highlight('Schema:')} ${DOCTOR_SCHEMA_VERSION}`);
448
+ console.log(`${highlight('OK:')} ${ok ? 'true' : 'false'} ${dim(`(pass=${checksSummary.pass} warn=${checksSummary.warn} fail=${checksSummary.fail})`)}`);
449
+
450
+ console.log(`\n${highlight('Active window:')}`);
451
+ if (activeWindow) {
452
+ const bounds = activeWindow.bounds || { x: '?', y: '?', width: '?', height: '?' };
453
+ console.log(` Title: ${activeWindow.title || dim('(unknown)')}`);
454
+ console.log(` Process: ${activeWindow.processName || dim('(unknown)')}`);
455
+ console.log(` Class: ${activeWindow.className || dim('(unknown)')}`);
456
+ console.log(` Handle: ${activeWindow.hwnd ?? dim('(unknown)')}`);
457
+ console.log(` Bounds: ${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}`);
458
+ } else {
459
+ error(`Could not read active window (${uiaError || 'unknown error'})`);
460
+ info('Tip: try running `liku window --active` to confirm UI Automation is working.');
461
+ }
462
+
463
+ if (mouse) {
464
+ console.log(`\n${highlight('Mouse:')} ${mouse.x},${mouse.y}`);
465
+ }
466
+
467
+ if (Array.isArray(windows) && windows.length > 0) {
468
+ console.log(`\n${highlight(`Top windows (${windows.length}${options.all ? '' : ' shown'}):`)}`);
469
+ windows.slice(0, 10).forEach((w, idx) => {
470
+ const title = w.title || '(untitled)';
471
+ const proc = w.processName || '-';
472
+ const hwnd = w.hwnd ?? '?';
473
+ console.log(` ${idx + 1}. [${hwnd}] ${title} ${dim('—')} ${proc}`);
474
+ });
475
+ if (windows.length > 10) {
476
+ console.log(dim(' (Use --windows <n> or --all with --json for more)'));
477
+ }
478
+ }
479
+
480
+ // Helpful next-step hints for browser operations
481
+ console.log(`\n${highlight('Targeting tips:')}`);
482
+ console.log(` - Before sending keys, ensure the intended app is active.`);
483
+ console.log(` - For browsers: activate the correct tab first, then use ${highlight('ctrl+w')} to close the active tab.`);
484
+
485
+ if (requestAnalysis?.plan?.length) {
486
+ console.log(`\n${highlight('Suggested plan:')}`);
487
+ requestAnalysis.plan.forEach((step, i) => {
488
+ console.log(` ${i + 1}. ${highlight(step.state)}: ${step.command}`);
489
+ });
490
+ if (options.flow && requestAnalysis.mermaid) {
491
+ console.log(`\n${highlight('Flow (Mermaid):')}\n${requestAnalysis.mermaid}`);
492
+ }
493
+ }
494
+
495
+ // For debugging copy/paste
496
+ if (options.debug) {
497
+ const json = safeJsonStringify(report);
498
+ if (json) {
499
+ console.log(`\n${highlight('Raw JSON:')}\n${json}`);
500
+ }
501
+ }
502
+
503
+ if (ok) success('Doctor check OK');
504
+ }
505
+
506
+ return report;
507
+ }
508
+
509
+ module.exports = { run };
package/src/cli/liku.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * liku - Copilot-Liku CLI
4
4
  *
@@ -36,6 +36,7 @@ const pkg = require(path.join(PROJECT_ROOT, 'package.json'));
36
36
  // Command registry
37
37
  const COMMANDS = {
38
38
  start: { desc: 'Start the Electron agent with overlay', file: 'start' },
39
+ doctor: { desc: 'Diagnostics: version, environment, active window', file: 'doctor' },
39
40
  click: { desc: 'Click element by text or coordinates', file: 'click', args: '<text|x,y>' },
40
41
  find: { desc: 'Find UI elements matching criteria', file: 'find', args: '<text>' },
41
42
  type: { desc: 'Type text at current cursor position', file: 'type', args: '<text>' },
@@ -18,6 +18,7 @@ const { VerifierAgent } = require('./verifier');
18
18
  const { ProducerAgent } = require('./producer');
19
19
  const { ResearcherAgent } = require('./researcher');
20
20
  const { AgentStateManager } = require('./state-manager');
21
+ const { TraceWriter } = require('./trace-writer');
21
22
 
22
23
  module.exports = {
23
24
  AgentOrchestrator,
@@ -27,6 +28,7 @@ module.exports = {
27
28
  ProducerAgent,
28
29
  ResearcherAgent,
29
30
  AgentStateManager,
31
+ TraceWriter,
30
32
 
31
33
  // Factory function for creating configured orchestrator
32
34
  createAgentSystem: (aiService, options = {}) => {
@@ -47,8 +49,11 @@ module.exports = {
47
49
  modelMetadata
48
50
  });
49
51
 
52
+ // Attach persistent flight recorder
53
+ const traceWriter = new TraceWriter(orchestrator);
54
+
50
55
  // Return object with both orchestrator and stateManager
51
- return { orchestrator, stateManager };
56
+ return { orchestrator, stateManager, traceWriter };
52
57
  },
53
58
 
54
59
  // Recovery function for checkpoint restoration
@@ -181,6 +181,33 @@ class AgentOrchestrator extends EventEmitter {
181
181
 
182
182
  // ===== Handoff Management =====
183
183
 
184
+ /**
185
+ * Execute multiple agents in parallel (e.g., Builder + Researcher)
186
+ * Returns array of results in the same order as the roles array.
187
+ */
188
+ async executeParallel(roles, context, message) {
189
+ const agents = roles.map(role => {
190
+ const agent = this.agents.get(role);
191
+ if (!agent) throw new Error(`Agent not found for parallel execution: ${role}`);
192
+ return { role, agent };
193
+ });
194
+
195
+ this.emit('parallel:start', { roles, message });
196
+
197
+ const task = { description: message, context };
198
+ const results = await Promise.all(
199
+ agents.map(({ role, agent }) => {
200
+ this.stateManager.updateAgentActivity(agent.id);
201
+ return agent.process(task, context).catch(err => ({
202
+ success: false, error: err.message, role
203
+ }));
204
+ })
205
+ );
206
+
207
+ this.emit('parallel:complete', { roles, results: results.map((r, i) => ({ role: roles[i], success: r.success })) });
208
+ return results;
209
+ }
210
+
184
211
  async executeHandoff(fromAgent, targetRole, context, message) {
185
212
  const targetAgent = this.agents.get(targetRole);
186
213