actoviq-agent-sdk 0.4.0 → 0.4.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.
Files changed (87) hide show
  1. package/README-zh.md +10 -1
  2. package/README.md +15 -6
  3. package/dist/src/cli/actoviq-react.js +178 -3
  4. package/dist/src/cli/actoviq-react.js.map +1 -1
  5. package/dist/src/cli/actoviq-tui.js +2 -2
  6. package/dist/src/index.d.ts +15 -0
  7. package/dist/src/index.d.ts.map +1 -1
  8. package/dist/src/index.js +15 -0
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/runtime/actoviqSkills.d.ts +6 -0
  11. package/dist/src/runtime/actoviqSkills.d.ts.map +1 -1
  12. package/dist/src/runtime/actoviqSkills.js +69 -1
  13. package/dist/src/runtime/actoviqSkills.js.map +1 -1
  14. package/dist/src/runtime/agentClient.d.ts +14 -0
  15. package/dist/src/runtime/agentClient.d.ts.map +1 -1
  16. package/dist/src/runtime/agentClient.js +84 -8
  17. package/dist/src/runtime/agentClient.js.map +1 -1
  18. package/dist/src/runtime/conversationEngine.d.ts +2 -0
  19. package/dist/src/runtime/conversationEngine.d.ts.map +1 -1
  20. package/dist/src/runtime/conversationEngine.js +6 -5
  21. package/dist/src/runtime/conversationEngine.js.map +1 -1
  22. package/dist/src/storage/sessionStore.d.ts.map +1 -1
  23. package/dist/src/storage/sessionStore.js +25 -6
  24. package/dist/src/storage/sessionStore.js.map +1 -1
  25. package/dist/src/team/agentPool.d.ts +26 -0
  26. package/dist/src/team/agentPool.d.ts.map +1 -0
  27. package/dist/src/team/agentPool.js +97 -0
  28. package/dist/src/team/agentPool.js.map +1 -0
  29. package/dist/src/team/modelTeam.d.ts +29 -0
  30. package/dist/src/team/modelTeam.d.ts.map +1 -0
  31. package/dist/src/team/modelTeam.js +879 -0
  32. package/dist/src/team/modelTeam.js.map +1 -0
  33. package/dist/src/team/pricing.d.ts +15 -0
  34. package/dist/src/team/pricing.d.ts.map +1 -0
  35. package/dist/src/team/pricing.js +80 -0
  36. package/dist/src/team/pricing.js.map +1 -0
  37. package/dist/src/team/teamDefinitions.d.ts +28 -0
  38. package/dist/src/team/teamDefinitions.d.ts.map +1 -0
  39. package/dist/src/team/teamDefinitions.js +162 -0
  40. package/dist/src/team/teamDefinitions.js.map +1 -0
  41. package/dist/src/tools/actoviqCoreTools.d.ts.map +1 -1
  42. package/dist/src/tools/actoviqCoreTools.js +6 -1
  43. package/dist/src/tools/actoviqCoreTools.js.map +1 -1
  44. package/dist/src/tools/enterWorktree.d.ts +11 -0
  45. package/dist/src/tools/enterWorktree.d.ts.map +1 -0
  46. package/dist/src/tools/enterWorktree.js +79 -0
  47. package/dist/src/tools/enterWorktree.js.map +1 -0
  48. package/dist/src/tools/exitWorktree.d.ts +4 -0
  49. package/dist/src/tools/exitWorktree.d.ts.map +1 -0
  50. package/dist/src/tools/exitWorktree.js +48 -0
  51. package/dist/src/tools/exitWorktree.js.map +1 -0
  52. package/dist/src/tools/tavilySearch.d.ts +12 -0
  53. package/dist/src/tools/tavilySearch.d.ts.map +1 -0
  54. package/dist/src/tools/tavilySearch.js +139 -0
  55. package/dist/src/tools/tavilySearch.js.map +1 -0
  56. package/dist/src/tui/actoviqTui.d.ts +10 -0
  57. package/dist/src/tui/actoviqTui.d.ts.map +1 -1
  58. package/dist/src/tui/actoviqTui.js +533 -34
  59. package/dist/src/tui/actoviqTui.js.map +1 -1
  60. package/dist/src/tui/editor.d.ts +2 -0
  61. package/dist/src/tui/editor.d.ts.map +1 -1
  62. package/dist/src/tui/editor.js +5 -0
  63. package/dist/src/tui/editor.js.map +1 -1
  64. package/dist/src/types.d.ts +309 -0
  65. package/dist/src/types.d.ts.map +1 -1
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/src/workflow/workflowPersistence.d.ts +34 -0
  68. package/dist/src/workflow/workflowPersistence.d.ts.map +1 -0
  69. package/dist/src/workflow/workflowPersistence.js +144 -0
  70. package/dist/src/workflow/workflowPersistence.js.map +1 -0
  71. package/dist/src/workflow/workflowScriptRuntime.d.ts +22 -0
  72. package/dist/src/workflow/workflowScriptRuntime.d.ts.map +1 -0
  73. package/dist/src/workflow/workflowScriptRuntime.js +655 -0
  74. package/dist/src/workflow/workflowScriptRuntime.js.map +1 -0
  75. package/dist/src/worktree/worktreeHooks.d.ts +38 -0
  76. package/dist/src/worktree/worktreeHooks.d.ts.map +1 -0
  77. package/dist/src/worktree/worktreeHooks.js +82 -0
  78. package/dist/src/worktree/worktreeHooks.js.map +1 -0
  79. package/dist/src/worktree/worktreeInclude.d.ts +11 -0
  80. package/dist/src/worktree/worktreeInclude.d.ts.map +1 -0
  81. package/dist/src/worktree/worktreeInclude.js +65 -0
  82. package/dist/src/worktree/worktreeInclude.js.map +1 -0
  83. package/dist/src/worktree/worktreeService.d.ts +43 -0
  84. package/dist/src/worktree/worktreeService.d.ts.map +1 -0
  85. package/dist/src/worktree/worktreeService.js +356 -0
  86. package/dist/src/worktree/worktreeService.js.map +1 -0
  87. package/package.json +2 -24
@@ -6,12 +6,14 @@
6
6
  * rendering (no React/Ink).
7
7
  */
8
8
  import { execSync } from 'node:child_process';
9
+ import fs from 'node:fs';
9
10
  import path from 'node:path';
10
11
  import * as readline from 'node:readline';
11
- import { createActoviqCoreTools, createAgentSdk, loadDefaultActoviqSettings, loadJsonConfigFile, } from '../index.js';
12
+ import { createActoviqCoreTools, createAgentSdk, loadDefaultActoviqSettings, loadJsonConfigFile, listWorkflows, loadWorkflow, listTeamDefinitions, loadTeamDefinition, createModelTeam, createTeamTool, WorktreeService, } from '../index.js';
12
13
  import { persistActoviqSettingsStore, resolveActoviqSettingsStore, } from '../config/actoviqSettingsStore.js';
13
14
  import { isRecord } from '../runtime/helpers.js';
14
- import { A, stringWidth, truncateToWidth } from './ansi.js';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { A, stringWidth, truncateToWidth, wrapToWidth } from './ansi.js';
15
17
  import { InputEditor } from './editor.js';
16
18
  import { discoverActoviqPlugins } from './pluginCatalog.js';
17
19
  import { TuiScreen } from './screen.js';
@@ -49,6 +51,9 @@ export const TUI_SLASH_COMMANDS = {
49
51
  mcp: 'Inspect MCP servers and tools',
50
52
  plugins: 'Browse discovered Clean plugins',
51
53
  dream: 'Inspect or run memory consolidation',
54
+ workflows: 'Browse saved dynamic workflows',
55
+ worktree: 'Enter, exit, or list git worktrees',
56
+ team: 'List or run Model Team definitions',
52
57
  exit: 'Quit',
53
58
  };
54
59
  export function filterSlashCommands(input) {
@@ -60,6 +65,56 @@ export function filterSlashCommands(input) {
60
65
  const partial = head.toLowerCase();
61
66
  return Object.keys(TUI_SLASH_COMMANDS).filter((name) => name.startsWith(partial));
62
67
  }
68
+ /**
69
+ * Detect an active "@file" mention at the cursor for path completion. Returns
70
+ * the partial token typed after the '@' plus the '@' offset, or null when the
71
+ * cursor is not inside a mention. The '@' only opens a mention at the start of
72
+ * input or after whitespace, and the token ends at the first whitespace.
73
+ */
74
+ export function activeAtToken(text, cursor) {
75
+ for (let i = cursor - 1; i >= 0; i -= 1) {
76
+ const ch = text[i];
77
+ if (/\s/.test(ch))
78
+ return null;
79
+ if (ch === '@') {
80
+ if (i === 0 || /\s/.test(text[i - 1])) {
81
+ return { token: text.slice(i + 1, cursor), start: i };
82
+ }
83
+ return null;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Render free-form text for the scrollback: width-aware word wrapping with
90
+ * markdown-lite heading highlighting. Used for workflow/team results and the
91
+ * expert-panel member reports so long output reads cleanly instead of dumping
92
+ * raw lines. Optionally caps very long output with a "… (N more lines)" note.
93
+ */
94
+ function renderRichText(text, width, opts = {}) {
95
+ const cols = Math.max(20, width - 2);
96
+ const out = [];
97
+ for (const raw of text.replace(/\r/g, '').split('\n')) {
98
+ const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
99
+ if (heading) {
100
+ out.push(`${A.bold}${A.cyan}${heading[2]}${A.reset}`);
101
+ continue;
102
+ }
103
+ if (raw.trim() === '') {
104
+ out.push('');
105
+ continue;
106
+ }
107
+ for (const line of wrapToWidth(raw, cols))
108
+ out.push(line);
109
+ }
110
+ const maxLines = opts.maxLines ?? 0;
111
+ if (maxLines > 0 && out.length > maxLines) {
112
+ const kept = out.slice(0, maxLines);
113
+ kept.push(`${A.dim}… (${out.length - maxLines} more lines)${A.reset}`);
114
+ return kept;
115
+ }
116
+ return out;
117
+ }
63
118
  function buildSystemPrompt(workDir) {
64
119
  let isGit = false;
65
120
  try {
@@ -133,11 +188,17 @@ export async function runActoviqTui(options = {}) {
133
188
  let running = false;
134
189
  let commandBusy = false;
135
190
  let shuttingDown = false;
191
+ // Active team tool the main agent may call (toggled via /team). null = no team.
192
+ let activeTeamTool = null;
193
+ let activeTeamName = null;
136
194
  let abortCtrl = null;
137
195
  let dialog = null;
138
196
  let selectionDialog = null;
139
197
  let textInputDialog = null;
140
198
  let menuSelected = 0;
199
+ // @-mention file completion: highlighted candidate + lazily-cached file list.
200
+ let atSelected = 0;
201
+ let workspaceFiles = null;
141
202
  let spinnerFrame = 0;
142
203
  let spinnerTimer = null;
143
204
  let dynamicRenderTimer = null;
@@ -302,8 +363,9 @@ export async function runActoviqTui(options = {}) {
302
363
  const lines = [];
303
364
  lines.push(promptDivider());
304
365
  if (editor.isEmpty()) {
305
- const placeholder = truncateToWidth('Try "write a test for <filepath>"', editorWidth - 2);
306
- lines.push(`${A.magenta}${PROMPT_GLYPH}${A.reset} ${A.dim}${placeholder}${A.reset}`);
366
+ // Always-visible block caret on the empty input so the box reads as active.
367
+ const placeholder = truncateToWidth('Try "write a test for <filepath>"', editorWidth - 4);
368
+ lines.push(`${A.magenta}${PROMPT_GLYPH}${A.reset} ${A.inverse} ${A.reset} ${A.dim}${placeholder}${A.reset}`);
307
369
  }
308
370
  else {
309
371
  const visual = editor.visualLines(editorWidth - 1);
@@ -316,21 +378,152 @@ export async function runActoviqTui(options = {}) {
316
378
  lines.push(promptDivider());
317
379
  return lines;
318
380
  }
381
+ // ── @-mention file completion ──────────────────────────────────────
382
+ // Prefer git's view (tracked + untracked, honoring .gitignore) so the list
383
+ // matches what the agent actually sees; fall back to a bounded fs walk for
384
+ // non-git workspaces. Cached for the session and invalidated after each run.
385
+ function loadWorkspaceFiles() {
386
+ if (workspaceFiles)
387
+ return workspaceFiles;
388
+ try {
389
+ const out = execSync('git ls-files --cached --others --exclude-standard', {
390
+ cwd: workDir,
391
+ encoding: 'utf8',
392
+ maxBuffer: 32 * 1024 * 1024,
393
+ stdio: ['ignore', 'pipe', 'ignore'],
394
+ });
395
+ workspaceFiles = out.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 8000);
396
+ }
397
+ catch {
398
+ workspaceFiles = walkWorkspaceFiles(workDir, 8000);
399
+ }
400
+ return workspaceFiles;
401
+ }
402
+ function walkWorkspaceFiles(root, limit) {
403
+ const skip = new Set(['node_modules', '.git', 'dist', '.codegraph', '.next', 'coverage']);
404
+ const out = [];
405
+ const stack = [root];
406
+ while (stack.length > 0 && out.length < limit) {
407
+ const dir = stack.pop();
408
+ let entries;
409
+ try {
410
+ entries = fs.readdirSync(dir, { withFileTypes: true });
411
+ }
412
+ catch {
413
+ continue;
414
+ }
415
+ for (const entry of entries) {
416
+ if (entry.isDirectory()) {
417
+ if (!skip.has(entry.name) && !entry.name.startsWith('.'))
418
+ stack.push(path.join(dir, entry.name));
419
+ }
420
+ else if (entry.isFile()) {
421
+ out.push(path.relative(root, path.join(dir, entry.name)).split(path.sep).join('/'));
422
+ if (out.length >= limit)
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ return out;
428
+ }
429
+ function atCompletions(token) {
430
+ const files = loadWorkspaceFiles();
431
+ const query = token.toLowerCase();
432
+ if (!query)
433
+ return files.slice(0, 200);
434
+ const matches = files.filter((file) => file.toLowerCase().includes(query));
435
+ matches.sort((a, b) => {
436
+ const aBase = a.slice(a.lastIndexOf('/') + 1).toLowerCase();
437
+ const bBase = b.slice(b.lastIndexOf('/') + 1).toLowerCase();
438
+ const aScore = aBase.startsWith(query) ? 0 : aBase.includes(query) ? 1 : 2;
439
+ const bScore = bBase.startsWith(query) ? 0 : bBase.includes(query) ? 1 : 2;
440
+ if (aScore !== bScore)
441
+ return aScore - bScore;
442
+ return a.length - b.length;
443
+ });
444
+ return matches.slice(0, 200);
445
+ }
446
+ function buildAtMenu() {
447
+ const active = activeAtToken(editor.text, editor.cursor);
448
+ if (!active)
449
+ return [];
450
+ const matches = atCompletions(active.token);
451
+ if (matches.length === 0) {
452
+ return [`${A.dim} @${active.token} — no matching files${A.reset}`];
453
+ }
454
+ if (atSelected >= matches.length)
455
+ atSelected = matches.length - 1;
456
+ if (atSelected < 0)
457
+ atSelected = 0;
458
+ const windowStart = Math.max(0, Math.min(atSelected - MENU_MAX_ROWS + 1, matches.length - MENU_MAX_ROWS));
459
+ const visible = matches.slice(windowStart, windowStart + MENU_MAX_ROWS);
460
+ const lines = visible.map((file, i) => {
461
+ const index = windowStart + i;
462
+ const display = truncateToWidth(file, Math.max(screen.width - 6, 12));
463
+ return index === atSelected ? `${A.inverse} @${display} ${A.reset}` : ` ${A.cyan}@${display}${A.reset}`;
464
+ });
465
+ const hiddenAbove = windowStart;
466
+ const hiddenBelow = matches.length - (windowStart + visible.length);
467
+ if (hiddenAbove > 0 || hiddenBelow > 0) {
468
+ const parts = [];
469
+ if (hiddenAbove > 0)
470
+ parts.push(`↑${hiddenAbove}`);
471
+ if (hiddenBelow > 0)
472
+ parts.push(`↓${hiddenBelow}`);
473
+ lines.push(`${A.dim} ${parts.join(' ')} more · ${atSelected + 1}/${matches.length} (↑/↓ · Tab/Enter to insert)${A.reset}`);
474
+ }
475
+ return lines;
476
+ }
477
+ /** Replace the active @-token with the highlighted file path. */
478
+ function applyAtCompletion() {
479
+ const active = activeAtToken(editor.text, editor.cursor);
480
+ if (!active)
481
+ return false;
482
+ const matches = atCompletions(active.token);
483
+ if (matches.length === 0)
484
+ return false;
485
+ const file = matches[Math.min(atSelected, matches.length - 1)];
486
+ const before = editor.text.slice(0, active.start);
487
+ const after = editor.text.slice(editor.cursor);
488
+ const mention = `@${file} `;
489
+ editor.setTextWithCursor(`${before}${mention}${after}`, before.length + mention.length);
490
+ atSelected = 0;
491
+ return true;
492
+ }
319
493
  function buildMenu() {
320
494
  const matches = filterSlashCommands(editor.text);
321
495
  if (matches.length === 0)
322
496
  return [];
323
497
  if (menuSelected >= matches.length)
324
498
  menuSelected = matches.length - 1;
499
+ if (menuSelected < 0)
500
+ menuSelected = 0;
325
501
  const commandWidth = Math.min(28, Math.max(14, Math.floor(screen.width * 0.28)));
326
502
  const descriptionWidth = Math.max(screen.width - commandWidth - 4, 12);
327
- return matches.slice(0, MENU_MAX_ROWS).map((name, index) => {
503
+ // Scroll a window of MENU_MAX_ROWS so the highlighted item stays visible and
504
+ // commands past the cap (e.g. /workflows, /worktree, /team) are reachable
505
+ // with the arrow keys instead of being clipped off the bottom.
506
+ const windowStart = Math.max(0, Math.min(menuSelected - MENU_MAX_ROWS + 1, matches.length - MENU_MAX_ROWS));
507
+ const visible = matches.slice(windowStart, windowStart + MENU_MAX_ROWS);
508
+ const lines = visible.map((name, i) => {
509
+ const index = windowStart + i;
328
510
  const selected = index === menuSelected;
329
511
  const command = `/${name}`.padEnd(commandWidth);
330
512
  const label = selected ? `${A.inverse}${command}${A.reset}` : command;
331
513
  const description = truncateToWidth(TUI_SLASH_COMMANDS[name] ?? '', descriptionWidth);
332
514
  return `${label} ${A.dim}${description}${A.reset}`;
333
515
  });
516
+ const hiddenAbove = windowStart;
517
+ const hiddenBelow = matches.length - (windowStart + visible.length);
518
+ if (hiddenAbove > 0 || hiddenBelow > 0) {
519
+ const parts = [];
520
+ if (hiddenAbove > 0)
521
+ parts.push(`↑${hiddenAbove}`);
522
+ if (hiddenBelow > 0)
523
+ parts.push(`↓${hiddenBelow}`);
524
+ lines.push(`${A.dim} ${parts.join(' ')} more · ${menuSelected + 1}/${matches.length} (↑/↓ to scroll)${A.reset}`);
525
+ }
526
+ return lines;
334
527
  }
335
528
  function buildDialog() {
336
529
  if (!dialog)
@@ -405,25 +598,45 @@ export async function runActoviqTui(options = {}) {
405
598
  lines.push(`${A.dim} enter confirm · esc cancel${textInputDialog.secret ? ' · value hidden' : ''}${A.reset}`);
406
599
  return lines;
407
600
  }
601
+ /** Friendly permission label matching the /permissions presets. */
602
+ function permissionLabel() {
603
+ const m = currentPermissionMode();
604
+ if (m === 'bypassPermissions')
605
+ return 'full-access';
606
+ if (m === 'acceptEdits')
607
+ return 'workspace';
608
+ if (m === 'default' && session.permissionContext.permissions.some((p) => p.behavior === 'deny'))
609
+ return 'read-only';
610
+ return m;
611
+ }
612
+ /** Always-visible mode + live context-usage line (usage shown as % of the window). */
613
+ function buildModeLine() {
614
+ const used = lastTokenEstimate ?? 0;
615
+ const window = sdk.config.compact?.contextWindowTokens ?? 200_000;
616
+ const pct = window > 0 ? Math.min(100, Math.round((used / window) * 100)) : 0;
617
+ const usedK = used >= 1000 ? `${(used / 1000).toFixed(used >= 100_000 ? 0 : 1)}k` : `${used}`;
618
+ const ctxColor = pct >= 90 ? A.red : pct >= 70 ? A.yellow : A.dim;
619
+ const left = `${session.model} · ${permissionLabel()} · effort:${currentEffort() ?? 'auto'} · team:${activeTeamName ?? 'none'} · `;
620
+ return `${A.dim} ${left}${A.reset}${ctxColor}ctx ${pct}% (${usedK})${A.reset}`;
621
+ }
408
622
  function buildStatusLine() {
623
+ const modeLine = buildModeLine();
409
624
  if (!running)
410
- return [];
625
+ return [modeLine];
411
626
  const elapsed = Math.max(Math.round((Date.now() - runStartedAt) / 1000), 0);
412
627
  const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
413
- const context = lastTokenEstimate && lastTokenEstimate >= 1000
414
- ? ` · ~${Math.round(lastTokenEstimate / 1000)}k ctx`
415
- : '';
416
628
  const note = statusNote ? ` · ${statusNote}` : '';
417
629
  const queued = queuedInputs.length > 0 ? ` · ${queuedInputs.length} queued` : '';
418
630
  return [
419
- `${A.cyan}${frame}${A.reset} ${A.bold}Working…${A.reset}${A.dim} (${elapsed}s · ${runToolCount} tool${runToolCount === 1 ? '' : 's'}${context}${note}${queued} · esc to interrupt)${A.reset}`,
631
+ `${A.cyan}${frame}${A.reset} ${A.bold}Working…${A.reset}${A.dim} (${elapsed}s · ${runToolCount} tool${runToolCount === 1 ? '' : 's'}${note}${queued} · esc to interrupt)${A.reset}`,
632
+ modeLine,
420
633
  ];
421
634
  }
422
635
  function buildHintLine() {
423
636
  if (running) {
424
637
  return [`${A.dim} enter to queue a steering message · esc interrupt · ctrl+c twice to exit${A.reset}`];
425
638
  }
426
- return [`${A.dim} ? for shortcuts · / for commands · \\↵ newline · ↑↓ history · ctrl+c clear/exit${A.reset}`];
639
+ return [`${A.dim} ? shortcuts · / commands · @ files · \\↵ newline · ↑↓ history · ctrl+c clear/exit${A.reset}`];
427
640
  }
428
641
  function renderDynamic() {
429
642
  const lines = [];
@@ -443,8 +656,14 @@ export async function runActoviqTui(options = {}) {
443
656
  }
444
657
  else {
445
658
  lines.push(...buildPromptBar());
446
- const menu = buildMenu();
447
- lines.push(...(menu.length > 0 ? menu : buildHintLine()));
659
+ const atMenu = buildAtMenu();
660
+ if (atMenu.length > 0) {
661
+ lines.push(...atMenu);
662
+ }
663
+ else {
664
+ const menu = buildMenu();
665
+ lines.push(...(menu.length > 0 ? menu : buildHintLine()));
666
+ }
448
667
  }
449
668
  screen.setDynamic(lines);
450
669
  }
@@ -487,6 +706,7 @@ export async function runActoviqTui(options = {}) {
487
706
  permissionMode: currentPermissionMode(),
488
707
  effort: currentEffort(),
489
708
  approver,
709
+ ...(activeTeamTool ? { tools: [...tools, activeTeamTool] } : {}),
490
710
  ...(canUseTool ? { canUseTool } : {}),
491
711
  drainQueuedInputs: () => {
492
712
  const drained = queuedInputs.splice(0);
@@ -523,6 +743,7 @@ export async function runActoviqTui(options = {}) {
523
743
  finally {
524
744
  running = false;
525
745
  abortCtrl = null;
746
+ workspaceFiles = null; // the agent may have created/removed files — refresh @-completion
526
747
  if (spinnerTimer) {
527
748
  clearInterval(spinnerTimer);
528
749
  spinnerTimer = null;
@@ -630,6 +851,9 @@ export async function runActoviqTui(options = {}) {
630
851
  mcp: '/mcp',
631
852
  plugins: '/plugins',
632
853
  dream: '/dream [run|status]',
854
+ workflows: '/workflows [run <name>|list]',
855
+ worktree: '/worktree [enter <name>|exit|list]',
856
+ team: '/team [ask <name> <prompt>|list]',
633
857
  exit: '/exit',
634
858
  };
635
859
  return usage[name] ?? `/${name}`;
@@ -884,15 +1108,17 @@ export async function runActoviqTui(options = {}) {
884
1108
  title: 'Skills',
885
1109
  items: skills.map(skill => ({
886
1110
  id: skill.name,
887
- label: skill.name,
888
- description: `${skill.source} · ${skill.context}`,
1111
+ label: skill.displayName ? `${skill.displayName} (${skill.name})` : skill.name,
1112
+ description: `${skill.source} · ${skill.context}${skill.version ? ` · v${skill.version}` : ''}`,
889
1113
  detail: `${skill.description} ${skill.whenToUse ?? ''}`,
890
1114
  })),
891
1115
  });
892
1116
  const skill = skills.find(item => item.name === selected);
893
1117
  if (skill) {
1118
+ const heading = skill.displayName ? `${skill.displayName} (/${skill.name})` : `/${skill.name}`;
1119
+ const ver = skill.version ? ` v${skill.version}` : '';
894
1120
  appendStatic([
895
- `${A.cyan}/${skill.name}${A.reset} ${skill.description}`,
1121
+ `${A.cyan}${heading}${A.reset}${A.dim}${ver}${A.reset} ${skill.description}`,
896
1122
  `${A.dim}${skill.whenToUse ?? `source: ${skill.source} · context: ${skill.context}`}${A.reset}`,
897
1123
  '',
898
1124
  ]);
@@ -1070,25 +1296,45 @@ export async function runActoviqTui(options = {}) {
1070
1296
  await setEffort(args.toLowerCase());
1071
1297
  return;
1072
1298
  case 'permissions': {
1073
- const requested = args;
1074
- if (!requested) {
1075
- const state = session.permissionContext;
1076
- appendStatic([
1077
- ...formatInfoLine(`permissions: ${state.mode ?? permissionMode} · ${state.permissions.length} session rules`),
1078
- '',
1079
- ]);
1080
- return;
1299
+ // Three presets, selectable or named directly:
1300
+ // read-only → deny mutating tools (read / search / web only)
1301
+ // workspace → acceptEdits (auto-accept edits in the workspace)
1302
+ // full → bypassPermissions (no prompts)
1303
+ const READONLY_DENY = ['Bash', 'Write', 'Edit', 'NotebookEdit', 'PowerShell'];
1304
+ const presets = {
1305
+ 'read-only': {
1306
+ mode: 'default',
1307
+ rules: READONLY_DENY.map((t) => ({ toolName: t, behavior: 'deny', source: 'permissions-preset' })),
1308
+ label: 'Read-only',
1309
+ },
1310
+ workspace: { mode: 'acceptEdits', rules: [], label: 'Workspace access' },
1311
+ full: { mode: 'bypassPermissions', rules: [], label: 'Full access' },
1312
+ };
1313
+ let key = args.trim().toLowerCase().replace(/[ _]/g, '-');
1314
+ if (!key) {
1315
+ const choice = await selectItem({
1316
+ title: 'Permission mode',
1317
+ subtitle: `current: ${session.permissionContext.mode ?? permissionMode}`,
1318
+ items: [
1319
+ { id: 'read-only', label: 'Read-only', description: 'Read, search, and web only — deny Write/Edit/Bash/NotebookEdit/PowerShell' },
1320
+ { id: 'workspace', label: 'Workspace access', description: 'Auto-accept edits in the workspace (acceptEdits)' },
1321
+ { id: 'full', label: 'Full access', description: 'No prompts — run any tool (bypassPermissions)' },
1322
+ ],
1323
+ });
1324
+ if (!choice)
1325
+ return;
1326
+ key = choice;
1081
1327
  }
1082
- if (!PERMISSION_MODES.has(requested)) {
1083
- appendStatic([...formatErrorLine(`unknown permission mode: ${requested}`), '']);
1328
+ const preset = presets[key];
1329
+ if (!preset) {
1330
+ appendStatic([...formatErrorLine(`unknown permission preset: ${key} (read-only | workspace | full)`), '']);
1084
1331
  return;
1085
1332
  }
1086
- await session.setPermissionContext({
1087
- mode: requested,
1088
- permissions: session.permissionContext.permissions,
1089
- approver,
1090
- });
1091
- appendStatic([...formatInfoLine(`permissions set to: ${requested}`), '']);
1333
+ await session.setPermissionContext({ mode: preset.mode, permissions: preset.rules, approver });
1334
+ appendStatic([
1335
+ ...formatInfoLine(`permissions: ${preset.label} — ${preset.mode}${preset.rules.length ? ` · ${preset.rules.length} deny rules` : ''}`),
1336
+ '',
1337
+ ]);
1092
1338
  return;
1093
1339
  }
1094
1340
  case 'sessions': {
@@ -1169,6 +1415,229 @@ export async function runActoviqTui(options = {}) {
1169
1415
  case 'plugins':
1170
1416
  await showPlugins();
1171
1417
  return;
1418
+ // ── v0.5.0: Dynamic Workflows ────────────────────────────
1419
+ case 'workflows': {
1420
+ const runSavedWorkflow = async (wfName, wfTask) => {
1421
+ const wf = loadWorkflow(wfName, sdk.config.workDir);
1422
+ if (!wf) {
1423
+ appendStatic([...formatErrorLine(`workflow not found: ${wfName}`), '']);
1424
+ return;
1425
+ }
1426
+ appendStatic([
1427
+ ...formatInfoLine(`running workflow: ${wfName}`),
1428
+ ...formatInfoLine(`phases: ${wf.meta?.phases?.map((p) => p.title).join(', ') ?? 'none'}`),
1429
+ '',
1430
+ ]);
1431
+ try {
1432
+ const { WorkflowScriptRuntime } = await import('../workflow/workflowScriptRuntime.js');
1433
+ const runtime = new WorkflowScriptRuntime({
1434
+ sdk: sdk,
1435
+ args: wfTask,
1436
+ onEvent: (e) => {
1437
+ if (e.type === 'workflow.phase.start') {
1438
+ appendStatic([`${A.bold}${A.magenta}▶ ${e.title}${A.reset}`]);
1439
+ }
1440
+ else if (e.type === 'workflow.agent.start') {
1441
+ appendStatic([`${A.dim} ⚡ ${e.label ?? e.agentId}${e.cached ? ' (cached)' : ''}${A.reset}`]);
1442
+ }
1443
+ else if (e.type === 'workflow.agent.done') {
1444
+ const secs = e.durationMs ? ` · ${Math.round(e.durationMs / 1000)}s` : '';
1445
+ appendStatic([`${A.dim} ✓ ${e.label ?? e.agentId}${secs}${A.reset}`]);
1446
+ }
1447
+ else if (e.type === 'workflow.log') {
1448
+ appendStatic([`${A.dim} │ ${e.message}${A.reset}`]);
1449
+ }
1450
+ else if (e.type === 'workflow.script.done') {
1451
+ const secs = e.durationMs ? ` · ${Math.round(e.durationMs / 1000)}s` : '';
1452
+ appendStatic([
1453
+ `${A.green}✓ workflow done${A.reset}${A.dim} · ${e.agentCount} agents · ${e.totalTokens} tokens${secs}${A.reset}`,
1454
+ '',
1455
+ ]);
1456
+ }
1457
+ },
1458
+ });
1459
+ const output = await runtime.execute(wf.script);
1460
+ if (typeof output.result === 'string' && output.result.trim()) {
1461
+ appendStatic([...formatInfoLine('workflow result:'), ...renderRichText(output.result, screen.width), '']);
1462
+ }
1463
+ if (output.state.errors.length > 0) {
1464
+ appendStatic([...formatErrorLine(`${output.state.errors.length} errors during workflow execution`), '']);
1465
+ }
1466
+ }
1467
+ catch (error) {
1468
+ appendStatic([...formatErrorLine(`workflow error: ${error.message}`), '']);
1469
+ }
1470
+ };
1471
+ if (args.startsWith('run ')) {
1472
+ const runRest = args.slice(4).trim();
1473
+ const runSpace = runRest.indexOf(' ');
1474
+ await runSavedWorkflow(runSpace === -1 ? runRest : runRest.slice(0, runSpace), runSpace === -1 ? undefined : runRest.slice(runSpace + 1).trim());
1475
+ return;
1476
+ }
1477
+ // No sub-command → selection picker.
1478
+ const saved = listWorkflows(sdk.config.workDir);
1479
+ const items = [
1480
+ ...saved.map((w) => ({
1481
+ id: `run:${w.name}`,
1482
+ label: w.name,
1483
+ description: `${w.source} · ${w.description}`.slice(0, 80),
1484
+ })),
1485
+ {
1486
+ id: '__orchestrate__',
1487
+ label: '+ ask the agent to orchestrate a new workflow',
1488
+ description: 'describe a task in the prompt box; the agent designs & runs a workflow, then you can save it',
1489
+ },
1490
+ ];
1491
+ const choice = await selectItem({
1492
+ title: 'Workflows',
1493
+ subtitle: 'run a saved workflow, or have the agent build a new one',
1494
+ items,
1495
+ });
1496
+ if (!choice)
1497
+ return;
1498
+ if (choice.startsWith('run:')) {
1499
+ const name = choice.slice('run:'.length);
1500
+ const task = await promptText({ title: `Run /${name}`, label: 'Task / input (optional — Enter to skip)' });
1501
+ await runSavedWorkflow(name, task && task.trim() ? task.trim() : undefined);
1502
+ }
1503
+ else if (choice === '__orchestrate__') {
1504
+ appendStatic([
1505
+ ...formatInfoLine('Type your task in the prompt box and ask: "orchestrate a workflow to <task>".'),
1506
+ `${A.dim}After it runs and works, ask me to save it as a reusable workflow.${A.reset}`,
1507
+ '',
1508
+ ]);
1509
+ }
1510
+ return;
1511
+ }
1512
+ // ── v0.5.0: Worktrees ────────────────────────────────────
1513
+ case 'worktree': {
1514
+ const ws = new WorktreeService(sdk.config.workDir);
1515
+ if (args === 'list') {
1516
+ await ws.init();
1517
+ const trees = await ws.listWorktrees();
1518
+ if (trees.length === 0) {
1519
+ appendStatic([...formatInfoLine('no worktrees'), '']);
1520
+ }
1521
+ else {
1522
+ appendStatic([
1523
+ ...trees.map((t) => `${A.dim}${t.path}${A.reset} · ${t.isDirty ? `${A.yellow}dirty${A.reset}` : `${A.green}clean${A.reset}`}`),
1524
+ '',
1525
+ ]);
1526
+ }
1527
+ return;
1528
+ }
1529
+ if (args === 'exit') {
1530
+ try {
1531
+ ws.exitWorktree();
1532
+ appendStatic([...formatInfoLine(`exited worktree, cwd: ${ws.currentWorkDir}`), '']);
1533
+ }
1534
+ catch (error) {
1535
+ appendStatic([...formatErrorLine(error.message), '']);
1536
+ }
1537
+ return;
1538
+ }
1539
+ if (args.startsWith('enter ')) {
1540
+ const wfName = args.slice(6).trim();
1541
+ try {
1542
+ await ws.init();
1543
+ await ws.createAndEnterWorktree({ name: wfName });
1544
+ appendStatic([...formatInfoLine(`entered worktree: ${wfName} (${ws.currentWorkDir})`), '']);
1545
+ }
1546
+ catch (error) {
1547
+ appendStatic([...formatErrorLine(error.message), '']);
1548
+ }
1549
+ return;
1550
+ }
1551
+ appendStatic([...formatInfoLine('usage: /worktree [enter <name>|exit|list]'), '']);
1552
+ return;
1553
+ }
1554
+ // ── v0.5.0: Model Team ───────────────────────────────────
1555
+ case 'team': {
1556
+ if (args.startsWith('ask ')) {
1557
+ const rest = args.slice(4).trim();
1558
+ const spaceIdx = rest.indexOf(' ');
1559
+ if (spaceIdx === -1) {
1560
+ appendStatic([...formatErrorLine('usage: /team ask <name> <prompt>'), '']);
1561
+ return;
1562
+ }
1563
+ const teamName = rest.slice(0, spaceIdx);
1564
+ const prompt = rest.slice(spaceIdx + 1).trim();
1565
+ const loaded = loadTeamDefinition(teamName, sdk.config.workDir);
1566
+ if (!loaded) {
1567
+ appendStatic([...formatErrorLine(`team not found: ${teamName}`), '']);
1568
+ return;
1569
+ }
1570
+ appendStatic([
1571
+ ...formatInfoLine(`asking team "${teamName}" (${loaded.definition.mode} mode)`),
1572
+ `${A.dim}convening: ${loaded.definition.members?.map((m) => m.model).join(', ') ?? 'configured members'}${A.reset}`,
1573
+ '',
1574
+ ]);
1575
+ try {
1576
+ const team = createModelTeam(loaded.definition);
1577
+ const result = await team.ask(prompt);
1578
+ appendStatic([
1579
+ `${A.green}✓ team response${A.reset}${A.dim} · ${result.mode} · ${Math.round(result.durationMs / 1000)}s${A.reset}`,
1580
+ `${A.dim}cost: ${result.cost.estimatedCost !== null ? `$${result.cost.estimatedCost.toFixed(4)}` : 'N/A'} · ${result.cost.totalInputTokens + result.cost.totalOutputTokens} tokens${A.reset}`,
1581
+ '',
1582
+ ...renderRichText(result.answer, screen.width),
1583
+ '',
1584
+ ]);
1585
+ }
1586
+ catch (error) {
1587
+ appendStatic([...formatErrorLine(`team error: ${error.message}`), '']);
1588
+ }
1589
+ return;
1590
+ }
1591
+ // No sub-command → picker that toggles which team the agent may call.
1592
+ const member = (sp) => ({ model: session.model, systemPrompt: sp });
1593
+ const buildDefault = (mode) => {
1594
+ switch (mode) {
1595
+ case 'analysis':
1596
+ return { name: 'analysis-panel', mode: 'analysis', members: [member('Expert researcher. Deep, source-grounded analysis.'), member('Rigorous skeptic. Verify with sources; challenge assumptions.')], timeoutMs: 300000, maxIterations: 12 };
1597
+ case 'panel':
1598
+ return { name: 'panel', mode: 'panel', members: [member('Thorough analyst.'), member('Creative problem-solver.')], primary: member('Synthesizer. Reconcile views into the best answer.'), timeoutMs: 300000 };
1599
+ case 'discussion':
1600
+ return { name: 'discussion', mode: 'discussion', members: [member('Systems thinker.'), member('Pragmatist who weighs trade-offs.')], primary: member('Convener and final decision-maker.'), timeoutMs: 300000 };
1601
+ case 'executor-reviewer':
1602
+ return { name: 'executor-reviewer', mode: 'executor-reviewer', members: [], executor: member('Executor. Own the output; you decide what to accept.'), reviewer: member('Reviewer. Advise; never command.'), timeoutMs: 300000 };
1603
+ default:
1604
+ return undefined;
1605
+ }
1606
+ };
1607
+ const saved = listTeamDefinitions(sdk.config.workDir);
1608
+ const items = [
1609
+ { id: '__none__', label: activeTeamTool ? `No team — remove "${activeTeamName}"` : 'No team (individual) — current', description: 'the agent works solo, no team tool attached' },
1610
+ ...saved.map((t) => ({ id: `saved:${t.name}`, label: t.name, description: `saved · ${t.definition.mode} · ${t.definition.members?.length ?? 0} members` })),
1611
+ ...['analysis', 'panel', 'discussion', 'executor-reviewer'].map((m) => ({ id: `mode:${m}`, label: `+ new ${m} team`, description: `built-in ${m} mode · default ${session.model} members` })),
1612
+ ];
1613
+ const choice = await selectItem({ title: 'Team', subtitle: 'attach a team the agent can call as a tool, or remove it', items });
1614
+ if (!choice)
1615
+ return;
1616
+ if (choice === '__none__') {
1617
+ activeTeamTool = null;
1618
+ activeTeamName = null;
1619
+ appendStatic([...formatInfoLine('team: none — the agent works individually'), '']);
1620
+ return;
1621
+ }
1622
+ let def;
1623
+ if (choice.startsWith('saved:'))
1624
+ def = loadTeamDefinition(choice.slice('saved:'.length), sdk.config.workDir)?.definition;
1625
+ else if (choice.startsWith('mode:'))
1626
+ def = buildDefault(choice.slice('mode:'.length));
1627
+ if (!def) {
1628
+ appendStatic([...formatErrorLine('could not load team definition'), '']);
1629
+ return;
1630
+ }
1631
+ try {
1632
+ activeTeamTool = createTeamTool(def);
1633
+ activeTeamName = def.name;
1634
+ appendStatic([...formatInfoLine(`team active: ${def.name} (${def.mode}) — the agent can now call "${def.name}" as a tool when it helps`), '']);
1635
+ }
1636
+ catch (error) {
1637
+ appendStatic([...formatErrorLine(`team error: ${error.message}`), '']);
1638
+ }
1639
+ return;
1640
+ }
1172
1641
  default:
1173
1642
  appendStatic([...formatErrorLine(`unknown command: /${name} — type /help`), '']);
1174
1643
  return;
@@ -1181,6 +1650,10 @@ export async function runActoviqTui(options = {}) {
1181
1650
  }
1182
1651
  // ── Submit / key handling ──────────────────────────────────────────
1183
1652
  async function submit() {
1653
+ if (!running && applyAtCompletion()) {
1654
+ renderDynamic();
1655
+ return;
1656
+ }
1184
1657
  const matches = filterSlashCommands(editor.text);
1185
1658
  if (matches.length > 0 && !running) {
1186
1659
  const selected = matches[Math.min(menuSelected, matches.length - 1)];
@@ -1469,6 +1942,7 @@ export async function runActoviqTui(options = {}) {
1469
1942
  case 'backspace':
1470
1943
  editor.backspace();
1471
1944
  menuSelected = 0;
1945
+ atSelected = 0;
1472
1946
  break;
1473
1947
  case 'delete':
1474
1948
  editor.deleteForward();
@@ -1492,8 +1966,13 @@ export async function runActoviqTui(options = {}) {
1492
1966
  editor.moveEnd();
1493
1967
  break;
1494
1968
  case 'up': {
1969
+ const atToken = activeAtToken(editor.text, editor.cursor);
1970
+ const atCount = atToken ? atCompletions(atToken.token).length : 0;
1495
1971
  const menu = filterSlashCommands(editor.text);
1496
- if (menu.length > 0) {
1972
+ if (atCount > 0) {
1973
+ atSelected = (atSelected + atCount - 1) % atCount;
1974
+ }
1975
+ else if (menu.length > 0) {
1497
1976
  menuSelected = (menuSelected + menu.length - 1) % menu.length;
1498
1977
  }
1499
1978
  else if (!editor.onFirstLine()) {
@@ -1505,8 +1984,13 @@ export async function runActoviqTui(options = {}) {
1505
1984
  break;
1506
1985
  }
1507
1986
  case 'down': {
1987
+ const atToken = activeAtToken(editor.text, editor.cursor);
1988
+ const atCount = atToken ? atCompletions(atToken.token).length : 0;
1508
1989
  const menu = filterSlashCommands(editor.text);
1509
- if (menu.length > 0) {
1990
+ if (atCount > 0) {
1991
+ atSelected = (atSelected + 1) % atCount;
1992
+ }
1993
+ else if (menu.length > 0) {
1510
1994
  menuSelected = (menuSelected + 1) % menu.length;
1511
1995
  }
1512
1996
  else if (!editor.onLastLine()) {
@@ -1518,6 +2002,8 @@ export async function runActoviqTui(options = {}) {
1518
2002
  break;
1519
2003
  }
1520
2004
  case 'tab': {
2005
+ if (applyAtCompletion())
2006
+ break;
1521
2007
  const menu = filterSlashCommands(editor.text);
1522
2008
  if (menu.length > 0) {
1523
2009
  const selected = menu[Math.min(menuSelected, menu.length - 1)];
@@ -1535,6 +2021,7 @@ export async function runActoviqTui(options = {}) {
1535
2021
  if (cleaned) {
1536
2022
  editor.insert(cleaned);
1537
2023
  menuSelected = 0;
2024
+ atSelected = 0;
1538
2025
  }
1539
2026
  }
1540
2027
  break;
@@ -1591,4 +2078,16 @@ export async function runActoviqTui(options = {}) {
1591
2078
  // Keep the process alive until shutdown() exits it.
1592
2079
  await new Promise(() => { });
1593
2080
  }
2081
+ // Allow running this module directly (`npx tsx src/tui/actoviqTui.ts`), not only
2082
+ // via the cli/ wrapper. Requires an interactive terminal.
2083
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2084
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2085
+ process.stderr.write('actoviq TUI requires an interactive terminal (TTY). Run it directly in your terminal — not piped or through another tool.\n');
2086
+ process.exit(1);
2087
+ }
2088
+ runActoviqTui().catch((error) => {
2089
+ process.stderr.write(`Fatal: ${error.stack ?? error.message}\n`);
2090
+ process.exit(1);
2091
+ });
2092
+ }
1594
2093
  //# sourceMappingURL=actoviqTui.js.map