lazyclaw 6.3.1 → 6.5.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.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
@@ -0,0 +1,68 @@
1
+ // tui/slash_helpers.mjs — shared leaf helpers for the slash-command dispatcher
2
+ // and its sibling handler modules. Extracted verbatim from slash_dispatcher.mjs
3
+ // so the dispatcher, dashboard, channels, and trainer modules share one set of
4
+ // pure utilities without a circular import. These import nothing from the
5
+ // dispatcher.
6
+
7
+ // Tiny utility — split args on whitespace, drop empties. Used by sub-command
8
+ // handlers that don't need the loop-engine's full quote-aware splitter.
9
+ export function splitWhitespace(s) {
10
+ return (s || '').split(/\s+/).filter(Boolean);
11
+ }
12
+
13
+ // Parse a "provider[:model]" spec, preferring the registry's parser.
14
+ export function _parseProvModel(registry, spec) {
15
+ if (registry && typeof registry.parseProviderModel === 'function') return registry.parseProviderModel(spec);
16
+ const s = String(spec || '');
17
+ const i = s.indexOf(':');
18
+ if (i < 0) return { provider: s || null, model: null };
19
+ return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
20
+ }
21
+
22
+ // Best-effort dynamic import. Returns the resolved ctx field if the caller
23
+ // pre-injected it (test hot path), else loads the real module. Throwing is
24
+ // fine — handlers wrap calls in try/catch where appropriate.
25
+ export async function _mod(ctx, key, importer) {
26
+ if (ctx && ctx[key]) return ctx[key];
27
+ return importer();
28
+ }
29
+
30
+ // Single free-text prompt reusing the modal's filter buffer (no dedicated
31
+ // input widget). Returns the typed value, '' (only when allowEmpty), or null
32
+ // on cancel / required-but-empty.
33
+ export async function _promptText(ctx, { title, subtitle, allowEmpty, secret } = {}) {
34
+ if (typeof ctx.openPicker !== 'function') return null;
35
+ const picked = await ctx.openPicker({
36
+ kind: 'text',
37
+ title,
38
+ // `secret` masks the typed query on screen (api-key / token entry) while
39
+ // the real value still reaches the caller — the modal picker honors it.
40
+ secret: !!secret,
41
+ subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
42
+ items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
43
+ });
44
+ if (picked == null) return null;
45
+ if (typeof picked === 'object') {
46
+ const v = String(picked.query || '').trim();
47
+ if (!v && !allowEmpty) return null;
48
+ return v;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // Yes/no confirmation modal for sensitive-tool approval. Esc (or no modal
54
+ // available) DENIES — approval is never granted by omission.
55
+ export async function _promptConfirm(ctx, { title, subtitle } = {}) {
56
+ if (typeof ctx.openPicker !== 'function') return false;
57
+ const picked = await ctx.openPicker({
58
+ kind: 'menu',
59
+ title: title || 'Approve sensitive tool?',
60
+ subtitle: subtitle || 'Enter selects · Esc denies',
61
+ items: [
62
+ { id: 'approve', label: '✓ approve once', desc: 'run this tool call' },
63
+ { id: 'deny', label: '✗ deny', desc: 'block this tool call' },
64
+ ],
65
+ });
66
+ const id = picked && typeof picked === 'object' ? picked.id : picked;
67
+ return id === 'approve';
68
+ }
@@ -86,6 +86,10 @@ export function SlashPopup({
86
86
  selectedIndex = 0,
87
87
  maxRows = 8,
88
88
  columns,
89
+ // When true, always render the selectable chooser (used by the arg popup,
90
+ // whose buffer has a space — which would otherwise collapse a single
91
+ // candidate into the non-selectable inline hint).
92
+ forceChooser = false,
89
93
  }) {
90
94
  if (!commands || commands.length === 0) return null;
91
95
  const cols = columns
@@ -97,7 +101,7 @@ export function SlashPopup({
97
101
 
98
102
  // Inline-hint mode: buffer already has args + a single match. Render
99
103
  // the help text on one dimmed line. No border, no chooser.
100
- const isInlineHint = buffer.includes(' ') && commands.length === 1;
104
+ const isInlineHint = !forceChooser && buffer.includes(' ') && commands.length === 1;
101
105
  if (isInlineHint) {
102
106
  const c = commands[0];
103
107
  return React.createElement(
@@ -0,0 +1,173 @@
1
+ // tui/slash_trainer.mjs — the /trainer slash-command handler, extracted verbatim
2
+ // from slash_dispatcher.mjs. Drives the synthesis / learning model selection
3
+ // (set / fallback / show / clear) plus the bare-menu picker. Imports its leaf
4
+ // helpers from slash_helpers.mjs and the shared provider/model picker from
5
+ // model_pick.mjs, never the dispatcher (no cycle). Recurses internally
6
+ // (_trainer('show', ctx)) — that recursion stays inside this module.
7
+
8
+ import { pickProviderModel, providerLookup as _providerLookup } from './model_pick.mjs';
9
+ import { renderRecord } from '../lib/render.mjs';
10
+ import { splitWhitespace, _mod, _parseProvModel, _promptConfirm } from './slash_helpers.mjs';
11
+
12
+ export async function _trainer(args, ctx) {
13
+ const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
14
+ const tokens = splitWhitespace(args);
15
+
16
+ // Bare /trainer with a modal available → an action menu (mirrors the bare
17
+ // /orchestrator menu). "Set"/"Fallback" re-enter and drill the shared
18
+ // provider→model picker; "Clear"/"Show" run their subcommands. Typed forms
19
+ // (/trainer set <p:m>, etc.) still work and skip the menu.
20
+ if (tokens.length === 0 && typeof ctx.openPicker === 'function') {
21
+ let cur = '';
22
+ try {
23
+ if (typeof registry.resolveTrainer === 'function') {
24
+ const e = registry.resolveTrainer(ctx.cfg || {});
25
+ cur = `now: ${e.provider}${e.model ? ':' + e.model : ':(default)'}`;
26
+ }
27
+ } catch { /* show menu without the current hint */ }
28
+ const picked = await ctx.openPicker({
29
+ kind: 'menu',
30
+ title: 'Trainer — synthesis / learning model',
31
+ subtitle: cur || 'pick provider + model for trainer turns',
32
+ items: [
33
+ { id: 'set', label: 'Set trainer…', desc: 'pick provider + model (or auto / provider default)' },
34
+ { id: 'fallback', label: 'Set fallback…', desc: 'pick a fallback provider + model' },
35
+ { id: 'clear', label: 'Clear', desc: 'unset — mirror the chat provider/model' },
36
+ { id: 'show', label: 'Show', desc: 'print the effective + configured trainer' },
37
+ ],
38
+ });
39
+ const id = picked && typeof picked === 'object' ? picked.id : picked;
40
+ if (!id || typeof id !== 'string') return _trainer('show', ctx); // cancelled → show status
41
+ return _trainer(id, ctx); // re-enter; set/fallback open the picker (no spec)
42
+ }
43
+
44
+ const sub = tokens[0] || 'show';
45
+
46
+ if (sub === 'show') {
47
+ let effective = { provider: ctx.getActiveProvName ? ctx.getActiveProvName() : null,
48
+ model: ctx.getActiveModel ? ctx.getActiveModel() : null };
49
+ try {
50
+ if (typeof registry.resolveTrainer === 'function') {
51
+ effective = registry.resolveTrainer(ctx.cfg || {});
52
+ }
53
+ } catch { /* fall through */ }
54
+ const configured = (ctx.cfg && ctx.cfg.trainer) || null;
55
+ const cfgRender = configured
56
+ ? renderRecord(configured, { fields: ['provider', 'model', 'fallback'] }).split('\n').map((l) => ' ' + l).join('\n')
57
+ : '(unset — trainer mirrors the chat provider/model)';
58
+ return [
59
+ 'trainer (effective):',
60
+ ` provider: ${effective.provider}`,
61
+ ` model: ${effective.model || '(default)'}`,
62
+ 'trainer (configured):',
63
+ cfgRender,
64
+ ].join('\n');
65
+ }
66
+
67
+ if (sub === 'set') {
68
+ let spec = tokens[1];
69
+ let _setFromPicker = false;
70
+ // No spec + a modal available → drill the shared picker (with an "auto"
71
+ // row and a "provider default" row) instead of requiring a typed spec.
72
+ if (!spec && typeof ctx.openPicker === 'function') {
73
+ const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
74
+ if (!r || r.model == null) return 'trainer set: cancelled';
75
+ spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
76
+ _setFromPicker = true;
77
+ }
78
+ if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
79
+ const parsed = typeof registry.parseProviderModel === 'function'
80
+ ? registry.parseProviderModel(spec)
81
+ : { provider: spec.split(':')[0], model: spec.split(':')[1] || null };
82
+ if (!parsed || !parsed.provider) return `/trainer set: could not parse "${spec}"`;
83
+ if (parsed.provider !== 'auto') {
84
+ const next = _providerLookup(registry, parsed.provider);
85
+ if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
86
+ }
87
+ // Optional `--fallback <provider[:model]>` — resolveTrainer routes here
88
+ // when opts.useFallback is set. Validate before persisting.
89
+ let fallbackSpec = null;
90
+ const fi = tokens.indexOf('--fallback');
91
+ if (fi >= 0) {
92
+ fallbackSpec = tokens[fi + 1];
93
+ if (!fallbackSpec) return 'usage: /trainer set <p:m> --fallback <p:m>';
94
+ const fp = _parseProvModel(registry, fallbackSpec);
95
+ if (!fp.provider) return `/trainer set: could not parse fallback "${fallbackSpec}"`;
96
+ if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
97
+ return `/trainer set: unknown provider "${fp.provider}"`;
98
+ }
99
+ }
100
+ // Picker-driven set with no --fallback flag → offer an optional fallback
101
+ // pick (the flag form stays the escape hatch for typed callers).
102
+ if (_setFromPicker && !fallbackSpec && typeof ctx.openPicker === 'function') {
103
+ const addFb = await _promptConfirm(ctx, { title: 'Add a fallback trainer?', subtitle: 'used when the primary is unavailable · Esc / deny to skip' });
104
+ if (addFb) {
105
+ const fr = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
106
+ if (fr && fr.model != null) {
107
+ fallbackSpec = fr.provider === 'auto' ? 'auto' : (fr.model ? `${fr.provider}:${fr.model}` : fr.provider);
108
+ }
109
+ }
110
+ }
111
+ // Read-merge-write so unrelated cfg keys survive.
112
+ const fs = await import('node:fs');
113
+ const path = await import('node:path');
114
+ const cfgPath = path.join(ctx.cfgDir, 'config.json');
115
+ let diskCfg = {};
116
+ try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
117
+ diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
118
+ if (parsed.model) diskCfg.trainer.model = parsed.model;
119
+ else delete diskCfg.trainer.model;
120
+ if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
121
+ try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
122
+ fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
123
+ if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
124
+ return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}${fallbackSpec ? ` (fallback: ${fallbackSpec})` : ''}`;
125
+ }
126
+
127
+ if (sub === 'fallback') {
128
+ let spec = tokens[1];
129
+ if (!spec && typeof ctx.openPicker === 'function') {
130
+ const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
131
+ if (!r || r.model == null) return 'trainer fallback: cancelled';
132
+ spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
133
+ }
134
+ if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
135
+ const fs = await import('node:fs');
136
+ const path = await import('node:path');
137
+ const cfgPath = path.join(ctx.cfgDir, 'config.json');
138
+ let diskCfg = {};
139
+ try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
140
+ if (spec === 'clear' || spec === 'unset') {
141
+ if (diskCfg.trainer) delete diskCfg.trainer.fallback;
142
+ try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
143
+ fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
144
+ if (ctx.cfg && ctx.cfg.trainer) delete ctx.cfg.trainer.fallback;
145
+ return '✓ trainer fallback cleared';
146
+ }
147
+ const fp = _parseProvModel(registry, spec);
148
+ if (!fp.provider) return `/trainer fallback: could not parse "${spec}"`;
149
+ if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
150
+ return `/trainer fallback: unknown provider "${fp.provider}"`;
151
+ }
152
+ diskCfg.trainer = { ...(diskCfg.trainer || {}), fallback: spec };
153
+ try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
154
+ fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
155
+ if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
156
+ return `✓ trainer fallback → ${spec}`;
157
+ }
158
+
159
+ if (sub === 'clear' || sub === 'unset') {
160
+ const fs = await import('node:fs');
161
+ const path = await import('node:path');
162
+ const cfgPath = path.join(ctx.cfgDir, 'config.json');
163
+ let diskCfg = {};
164
+ try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
165
+ delete diskCfg.trainer;
166
+ try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
167
+ fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
168
+ if (ctx.cfg) delete ctx.cfg.trainer;
169
+ return '✓ trainer cleared (will mirror chat provider/model)';
170
+ }
171
+
172
+ return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
173
+ }
package/tui/splash.mjs CHANGED
@@ -49,6 +49,19 @@ function shortCwd(cwd) {
49
49
  return cwd;
50
50
  }
51
51
 
52
+ // Provider-aware splash tip. The $0 "learning loop" pitch only applies when
53
+ // the trainer runs on claude-cli (Claude Pro/Max subscription is keyless and
54
+ // free); for any other provider the loop costs API tokens, so showing the
55
+ // Claude pitch is misleading. The trainer provider wins because the pitch is
56
+ // about the learning loop, not the chat provider (see splash info line).
57
+ export function pickSplashTip({ provider, trainer = {} } = {}) {
58
+ const trainerProvider = trainer.provider || provider;
59
+ if (trainerProvider === 'claude-cli') {
60
+ return 'Tip: trainer learns from your Claude Pro subscription at $0.';
61
+ }
62
+ return 'Tip: /help lists every command, tool, and skill.';
63
+ }
64
+
52
65
  function toolRow({ category, sensitive, verbs }) {
53
66
  const label = sensitive ? `${category}*` : category;
54
67
  const tail = verbs.slice(0, 6).join(' · ');
@@ -153,7 +166,7 @@ function renderWide(props, cols) {
153
166
  if (sessionId) lines.push(`${LMARGIN}Session: ${sessionId}`);
154
167
  lines.push('');
155
168
  lines.push(`${LMARGIN}Welcome to lazyclaw. Type your message or /help for commands.`);
156
- lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
169
+ lines.push(`${LMARGIN}+ ${pickSplashTip({ provider, trainer })}`);
157
170
  // v5.4.3 — the baked-in status row that used to live here duplicated
158
171
  // ReplApp's real <StatusBar/> (tui/repl.mjs:476). Removing it cuts 4
159
172
  // rows from the splash AND eliminates the visible overlap the user
@@ -237,7 +250,7 @@ function renderMedium(props, cols) {
237
250
  if (sessionId) lines.push(`${LMARGIN}Session: ${sessionId}`);
238
251
  lines.push('');
239
252
  lines.push(`${LMARGIN}Welcome to lazyclaw. Type your message or /help for commands.`);
240
- lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
253
+ lines.push(`${LMARGIN}+ ${pickSplashTip({ provider, trainer })}`);
241
254
  // v5.4.3 — baked-in status row removed; ReplApp renders the real one.
242
255
 
243
256
  return lines;
@@ -0,0 +1,26 @@
1
+ // tui/status_bar.mjs — the sticky single/double status row above the chat
2
+ // input. Row 1: streaming indicator · provider · model · ctx gauge. Row 2
3
+ // (only when the HUD is enabled): real-time usage, cost, trainer, orchestrator.
4
+ // Extracted from repl.mjs so the HUD can grow without pushing repl.mjs over the
5
+ // file-size ratchet.
6
+
7
+ import React from 'react';
8
+ import { Box, Text } from 'ink';
9
+ import { theme } from './theme.mjs';
10
+ import { formatHudRow, formatGauge } from './hud.mjs';
11
+
12
+ export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal, hud }) {
13
+ // Numbers are computed upstream (chat-history budget, not provider self-report);
14
+ // formatGauge only changes the RENDERING — adds percent + bar + warn marker.
15
+ const ctx = (ctxUsed != null && ctxTotal != null) ? formatGauge(ctxUsed, ctxTotal) : '--';
16
+ const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
17
+ const prov = provider || '?';
18
+ const mdl = model || '?';
19
+ const hudRow = hud ? formatHudRow(hud) : '';
20
+ return React.createElement(
21
+ Box,
22
+ { flexShrink: 0, flexDirection: 'column', paddingX: 1 },
23
+ React.createElement(Text, null, `${indicator} ${prov} · ${mdl} ctx ${ctx}`),
24
+ hudRow ? React.createElement(Text, null, theme.dim(hudRow)) : null,
25
+ );
26
+ }
package/tui/theme.mjs CHANGED
@@ -10,6 +10,34 @@ import chalk from 'chalk';
10
10
  const AMBER_HEX = '#FFB347';
11
11
  const BORDER_HEX = '#5A5A5A';
12
12
 
13
+ // Central color gate. Color is OFF when the NO_COLOR standard applies
14
+ // (https://no-color.org — any non-empty NO_COLOR), the terminal is `dumb`,
15
+ // or the target stream isn't a TTY (piped / screen-reader / CI). Evaluated
16
+ // per call so tests and runtime env changes are honored without re-importing.
17
+ // `stream` defaults to stdout; tests pass a fake `{ isTTY }` to probe the
18
+ // gate without mutating the real process streams. chalk self-detects the same
19
+ // conditions (level 0), so its tokens degrade automatically; this gate is for
20
+ // callers that emit raw ANSI.
21
+ export function colorEnabled(stream = process.stdout) {
22
+ if (process.env.NO_COLOR) return false;
23
+ if (process.env.TERM === 'dumb') return false;
24
+ if (!stream || !stream.isTTY) return false;
25
+ return true;
26
+ }
27
+
28
+ // Wrap `str` in a raw SGR escape (`\x1b[<code>m … \x1b[0m`) when color is on,
29
+ // otherwise return it unchanged. Keeps inline-ANSI call sites accessible
30
+ // without each one re-implementing the NO_COLOR check.
31
+ export function paint(code, str) {
32
+ if (!colorEnabled()) return str;
33
+ return `\x1b[${code}m${str}\x1b[0m`;
34
+ }
35
+
36
+ // Align chalk's own level with our gate so theme tokens (amber/dim/accent)
37
+ // and raw-ANSI emitters agree under NO_COLOR / dumb / non-TTY. chalk reads
38
+ // this at import; our gate stays the runtime source of truth for raw ANSI.
39
+ if (!colorEnabled()) chalk.level = 0;
40
+
13
41
  function amber(text) {
14
42
  return chalk.hex(AMBER_HEX)(text);
15
43
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/web/dashboard.css CHANGED
@@ -267,3 +267,80 @@
267
267
  display: flex;
268
268
  justify-content: space-between;
269
269
  }
270
+
271
+ /* ── Team Live tab ─────────────────────────────────────────── */
272
+ .team-live { display: flex; gap: 16px; align-items: stretch; flex-wrap: wrap; }
273
+ .team-canvas {
274
+ flex: 2 1 420px; min-width: 0; min-height: 260px;
275
+ background: var(--card); border: 1px solid var(--border); border-radius: 12px;
276
+ padding: 20px; display: flex; flex-direction: column; align-items: center; gap: 18px;
277
+ }
278
+ .team-detail {
279
+ flex: 1 1 280px; min-width: 0;
280
+ background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px;
281
+ font-size: 13px;
282
+ }
283
+ /* tree: a lead row on top, a wrapping children row below, with connectors */
284
+ .team-row { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; }
285
+ .team-children { position: relative; padding-top: 18px; }
286
+ .team-children::before {
287
+ content: ''; position: absolute; top: 0; left: 50%; width: 1px; height: 18px;
288
+ background: var(--border); transform: translateX(-50%);
289
+ }
290
+ .tagent {
291
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
292
+ width: 104px; cursor: pointer; background: none; border: 0; color: inherit; font: inherit;
293
+ padding: 6px; border-radius: 10px;
294
+ }
295
+ .tagent:hover { background: rgba(217,179,90,0.06); }
296
+ .tagent:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
297
+ .tagent[aria-selected="true"] { background: rgba(217,179,90,0.12); }
298
+ .tagent-avatar {
299
+ width: 56px; height: 56px; border-radius: 50%;
300
+ position: relative; overflow: hidden;
301
+ display: flex; align-items: center; justify-content: center; font-size: 24px;
302
+ background: #1e2130; border: 3px solid #475569;
303
+ }
304
+ .tagent-avatar img {
305
+ position: absolute; inset: 0; width: 100%; height: 100%;
306
+ object-fit: cover; object-position: center top;
307
+ image-rendering: pixelated;
308
+ }
309
+ .detail-avatar {
310
+ width: 30px; height: 30px; border-radius: 50%; object-fit: cover;
311
+ object-position: center top; image-rendering: pixelated; vertical-align: middle;
312
+ margin-right: 8px; background: #1e2130; border: 2px solid #475569;
313
+ }
314
+ .tagent.working .tagent-avatar { border-color: #34d399; box-shadow: 0 0 0 4px rgba(52,211,153,0.16); }
315
+ .tagent.idle .tagent-avatar { border-color: #475569; }
316
+ .tagent.delegating .tagent-avatar { animation: tdelegate 900ms ease-out; }
317
+ @keyframes tdelegate {
318
+ 0% { box-shadow: 0 0 0 0 rgba(217,179,90,0.55); }
319
+ 100% { box-shadow: 0 0 0 14px rgba(217,179,90,0); }
320
+ }
321
+ .tagent-name { font-size: 12px; font-weight: 600; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
322
+ .tagent-status { font-size: 10px; }
323
+ .tagent.working .tagent-status { color: #34d399; }
324
+ .tagent.idle .tagent-status { color: var(--dim); }
325
+ .harness-badge {
326
+ font-size: 9px; background: #312e81; color: #c7d2fe; border-radius: 5px; padding: 1px 6px;
327
+ max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
328
+ }
329
+ .team-detail h3 { margin: 0 0 6px; font-size: 15px; }
330
+ .team-detail .kv { margin: 8px 0; }
331
+ .team-detail .kv .label { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
332
+ .team-detail .activity { margin: 3px 0; color: #cbd5e1; line-height: 1.6; }
333
+ .team-feed-wrap { margin-top: 16px; }
334
+ .team-feed { list-style: none; margin: 6px 0 0; padding: 0; max-height: 180px; overflow-y: auto; font-size: 12px; }
335
+ .team-feed li { padding: 4px 0; border-top: 1px solid var(--border); color: var(--dim); }
336
+ .team-feed li .who { color: var(--fg, #e8e8f0); font-weight: 600; }
337
+ .team-feed li .arrow { color: #34d399; }
338
+
339
+ @media (max-width: 760px) {
340
+ .team-live { flex-direction: column; }
341
+ .team-canvas, .team-detail { flex: 1 1 auto; }
342
+ }
343
+ @media (prefers-reduced-motion: reduce) {
344
+ .tagent.working .tagent-avatar { box-shadow: none; }
345
+ .tagent.delegating .tagent-avatar { animation: none; }
346
+ }
@@ -14,7 +14,11 @@
14
14
  <meta charset="utf-8">
15
15
  <meta name="viewport" content="width=device-width, initial-scale=1">
16
16
  <title>LazyClaw</title>
17
- <link rel="stylesheet" href="dashboard.css">
17
+ <!-- Absolute paths: the daemon serves this page at /dashboard AND /dashboard/
18
+ (and /), so a relative href would resolve to /dashboard/dashboard.css and
19
+ 404 — leaving the page unstyled with every tab stuck on "Loading…". -->
20
+ <link rel="stylesheet" href="/dashboard.css">
21
+ <link rel="icon" href="data:,">
18
22
  </head>
19
23
  <body>
20
24
  <header>
@@ -38,6 +42,7 @@
38
42
  <button data-tab="agents">Agents</button>
39
43
  <button data-tab="teams">Teams</button>
40
44
  <button data-tab="tasks">Tasks</button>
45
+ <button data-tab="team">Team Live</button>
41
46
  <button data-tab="trainer">Trainer</button>
42
47
  <button data-tab="recall">Recall</button>
43
48
  <button data-tab="sandbox">Sandbox</button>
@@ -182,6 +187,28 @@
182
187
  <div id="tasks-list"><div class="empty">Loading…</div></div>
183
188
  </section>
184
189
 
190
+ <section id="tab-team">
191
+ <h2>Team Live</h2>
192
+ <div class="toolbar">
193
+ <label for="team-select" class="dim">Team</label>
194
+ <select id="team-select" aria-label="Select a team to watch"></select>
195
+ <button class="btn btn-secondary" onclick="LOADERS.team()">Refresh</button>
196
+ <span class="dim" id="team-conn" role="status" aria-live="polite">○ connecting…</span>
197
+ </div>
198
+ <div id="team-live" class="team-live">
199
+ <div id="team-canvas" class="team-canvas" role="tree" aria-label="Agent team">
200
+ <div class="empty">Loading…</div>
201
+ </div>
202
+ <aside id="team-detail" class="team-detail" aria-live="polite">
203
+ <div class="empty">Click an agent to see its harness and what it's working on.</div>
204
+ </aside>
205
+ </div>
206
+ <div class="team-feed-wrap">
207
+ <div class="label">Live activity</div>
208
+ <ul id="team-feed" class="team-feed" aria-live="polite"></ul>
209
+ </div>
210
+ </section>
211
+
185
212
  <section id="tab-trainer">
186
213
  <h2>Trainer</h2>
187
214
  <div class="toolbar">
@@ -244,6 +271,6 @@
244
271
  </div>
245
272
  </div>
246
273
 
247
- <script src="dashboard.js"></script>
274
+ <script src="/dashboard.js"></script>
248
275
  </body>
249
276
  </html>