@pellux/goodvibes-agent 0.1.111 → 0.1.113

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 (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/dist/package/main.js +9921 -9868
  4. package/docs/getting-started.md +3 -3
  5. package/package.json +1 -1
  6. package/src/cli/help.ts +4 -1
  7. package/src/cli/local-library-command.ts +90 -1
  8. package/src/cli/parser.ts +0 -8
  9. package/src/cli/service-posture.ts +1 -8
  10. package/src/cli/status.ts +1 -30
  11. package/src/cli/types.ts +0 -1
  12. package/src/input/agent-workspace-categories.ts +5 -7
  13. package/src/input/agent-workspace-snapshot.ts +2 -10
  14. package/src/input/agent-workspace-types.ts +2 -3
  15. package/src/input/commands/brief-runtime.ts +1 -1
  16. package/src/input/commands/channels-runtime.ts +397 -35
  17. package/src/input/commands/experience-runtime.ts +4 -9
  18. package/src/input/commands/health-runtime.ts +26 -6
  19. package/src/input/commands/mcp-runtime.ts +51 -26
  20. package/src/input/commands/memory.ts +10 -10
  21. package/src/input/commands/planning-runtime.ts +4 -9
  22. package/src/input/commands/provider-accounts-runtime.ts +2 -3
  23. package/src/input/commands/qrcode-runtime.ts +48 -9
  24. package/src/input/commands/recall-bundle.ts +16 -16
  25. package/src/input/commands/recall-capture.ts +18 -18
  26. package/src/input/commands/recall-query.ts +22 -22
  27. package/src/input/commands/recall-review.ts +12 -12
  28. package/src/input/commands/tasks-runtime.ts +9 -15
  29. package/src/input/commands/work-plan-runtime.ts +5 -19
  30. package/src/input/commands.ts +2 -7
  31. package/src/renderer/agent-workspace.ts +1 -2
  32. package/src/renderer/ui-factory.ts +1 -1
  33. package/src/runtime/onboarding/derivation.ts +6 -67
  34. package/src/version.ts +1 -1
@@ -1,47 +1,409 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import type { CommandRegistry } from '../command-registry.ts';
4
+ import type { CommandContext } from '../command-registry.ts';
5
+ import type { AgentWorkspaceChannelStatus } from '../agent-workspace-channels.ts';
2
6
  import { buildAgentWorkspaceChannels } from '../agent-workspace-channels.ts';
3
7
 
8
+ type ChannelFilter = 'all' | 'ready' | 'attention';
9
+ type JsonRecord = Record<string, unknown>;
10
+
11
+ interface ChannelDaemonConnection {
12
+ readonly baseUrl: string;
13
+ readonly token: string | null;
14
+ readonly tokenPath: string;
15
+ }
16
+
17
+ interface ChannelRouteSuccess {
18
+ readonly ok: true;
19
+ readonly route: string;
20
+ readonly body: unknown;
21
+ }
22
+
23
+ interface ChannelRouteFailure {
24
+ readonly ok: false;
25
+ readonly route: string;
26
+ readonly kind: 'auth_required' | 'daemon_unavailable' | 'route_unavailable' | 'daemon_error';
27
+ readonly message: string;
28
+ }
29
+
30
+ type ChannelRouteResult = ChannelRouteSuccess | ChannelRouteFailure;
31
+
32
+ function isRecord(value: unknown): value is JsonRecord {
33
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ function readString(record: JsonRecord, key: string, fallback = ''): string {
37
+ const value = record[key];
38
+ return typeof value === 'string' ? value : fallback;
39
+ }
40
+
41
+ function readBoolean(record: JsonRecord, key: string, fallback = false): boolean {
42
+ const value = record[key];
43
+ return typeof value === 'boolean' ? value : fallback;
44
+ }
45
+
46
+ function readRecordArray(record: JsonRecord, key: string): readonly JsonRecord[] {
47
+ const value = record[key];
48
+ return Array.isArray(value) ? value.filter(isRecord) : [];
49
+ }
50
+
51
+ function resolveChannelDaemonConnection(context: CommandContext): ChannelDaemonConnection {
52
+ const hostValue = context.platform?.configManager?.get('controlPlane.host');
53
+ const portValue = context.platform?.configManager?.get('controlPlane.port');
54
+ const host = typeof hostValue === 'string' && hostValue.trim().length > 0 ? hostValue.trim() : '127.0.0.1';
55
+ const port = typeof portValue === 'number' && Number.isFinite(portValue) ? portValue : 3421;
56
+ const homeDirectory = context.workspace?.shellPaths?.homeDirectory ?? process.env.HOME ?? '';
57
+ const tokenPath = join(homeDirectory, '.goodvibes', 'daemon', 'operator-tokens.json');
58
+ if (!existsSync(tokenPath)) return { baseUrl: `http://${host}:${port}`, token: null, tokenPath };
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(tokenPath, 'utf-8')) as unknown;
61
+ const token = isRecord(parsed) && typeof parsed.token === 'string' ? parsed.token : null;
62
+ return { baseUrl: `http://${host}:${port}`, token, tokenPath };
63
+ } catch {
64
+ return { baseUrl: `http://${host}:${port}`, token: null, tokenPath };
65
+ }
66
+ }
67
+
68
+ async function fetchChannelRoute(context: CommandContext, route: string): Promise<ChannelRouteResult> {
69
+ const connection = resolveChannelDaemonConnection(context);
70
+ if (!connection.token) {
71
+ return {
72
+ ok: false,
73
+ route,
74
+ kind: 'auth_required',
75
+ message: `No runtime operator token found at ${connection.tokenPath}`,
76
+ };
77
+ }
78
+
79
+ try {
80
+ const response = await fetch(`${connection.baseUrl}${route}`, {
81
+ headers: { authorization: `Bearer ${connection.token}` },
82
+ });
83
+ const text = await response.text();
84
+ let body: unknown = text;
85
+ if (text.trim().length > 0) {
86
+ try {
87
+ body = JSON.parse(text) as unknown;
88
+ } catch {
89
+ body = text;
90
+ }
91
+ }
92
+ if (!response.ok) {
93
+ const detail = isRecord(body) && typeof body.error === 'string' ? body.error : text;
94
+ return {
95
+ ok: false,
96
+ route,
97
+ kind: response.status === 401 || response.status === 403
98
+ ? 'auth_required'
99
+ : response.status === 404
100
+ ? 'route_unavailable'
101
+ : 'daemon_error',
102
+ message: `HTTP ${response.status}${detail ? `: ${detail}` : ''}`,
103
+ };
104
+ }
105
+ return { ok: true, route, body };
106
+ } catch (error) {
107
+ return {
108
+ ok: false,
109
+ route,
110
+ kind: 'daemon_unavailable',
111
+ message: error instanceof Error ? error.message : String(error),
112
+ };
113
+ }
114
+ }
115
+
116
+ function formatChannelRouteFailure(title: string, failure: ChannelRouteFailure): string {
117
+ return [
118
+ `${title}: unavailable`,
119
+ ` kind: ${failure.kind}`,
120
+ ` route: ${failure.route}`,
121
+ ` error: ${failure.message}`,
122
+ ' policy: read-only; no channel send/action route was called',
123
+ ].join('\n');
124
+ }
125
+
126
+ function formatChannelLine(channel: AgentWorkspaceChannelStatus): string {
127
+ const missing = channel.missingRequiredKeys.length > 0
128
+ ? ` missing=${channel.missingRequiredKeys.join('|')}`
129
+ : '';
130
+ const target = channel.configuredDefaultTargetKeys.length > 0
131
+ ? ` target=${channel.configuredDefaultTargetKeys.join('|')}`
132
+ : channel.defaultTargetKeys.length > 0
133
+ ? ` target=missing(${channel.defaultTargetKeys.join('|')})`
134
+ : ' target=not-required';
135
+ return [
136
+ ` ${channel.label}: ${channel.setupState}`,
137
+ `ready=${channel.ready ? 'yes' : 'no'}`,
138
+ `delivery=${channel.delivery}`,
139
+ `risk=${channel.risk}`,
140
+ target,
141
+ missing,
142
+ ].join(' ');
143
+ }
144
+
145
+ function filterChannels(
146
+ channels: readonly AgentWorkspaceChannelStatus[],
147
+ filter: ChannelFilter,
148
+ ): readonly AgentWorkspaceChannelStatus[] {
149
+ if (filter === 'ready') return channels.filter((channel) => channel.ready);
150
+ if (filter === 'attention') return channels.filter((channel) => channel.enabled && channel.setupState !== 'ready');
151
+ return channels;
152
+ }
153
+
154
+ function printChannelSummary(
155
+ print: (message: string) => void,
156
+ channels: readonly AgentWorkspaceChannelStatus[],
157
+ filter: ChannelFilter,
158
+ ): void {
159
+ const ready = channels.filter((channel) => channel.ready);
160
+ const enabled = channels.filter((channel) => channel.enabled);
161
+ const needsTarget = channels.filter((channel) => channel.setupState === 'needs-target');
162
+ const needsConfig = channels.filter((channel) => channel.setupState === 'needs-config');
163
+ const filtered = filterChannels(channels, filter);
164
+ const title = filter === 'ready'
165
+ ? 'Channel Readiness: Ready'
166
+ : filter === 'attention'
167
+ ? 'Channel Readiness: Needs Attention'
168
+ : 'Channel Readiness';
169
+ const lines: string[] = [
170
+ title,
171
+ ` ready: ${ready.length}/${channels.length}`,
172
+ ` enabled: ${enabled.length}/${channels.length}`,
173
+ ` needs target: ${needsTarget.length}`,
174
+ ` needs config: ${needsConfig.length}`,
175
+ ' policy: read-only inspection; sends require explicit user action and Agent policy',
176
+ ' details: /channels show <id>',
177
+ '',
178
+ ...(filtered.length > 0
179
+ ? filtered.map(formatChannelLine)
180
+ : [` No ${filter === 'ready' ? 'ready' : 'attention'} channels matched.`]),
181
+ ];
182
+ print(lines.join('\n'));
183
+ }
184
+
185
+ function printChannelDetail(
186
+ print: (message: string) => void,
187
+ channel: AgentWorkspaceChannelStatus,
188
+ ): void {
189
+ print([
190
+ `Channel: ${channel.label} (${channel.id})`,
191
+ ` state: ${channel.setupState}`,
192
+ ` enabled: ${channel.enabled ? 'yes' : 'no'}`,
193
+ ` ready: ${channel.ready ? 'yes' : 'no'}`,
194
+ ` delivery: ${channel.delivery}`,
195
+ ` risk: ${channel.risk} (${channel.riskLabel})`,
196
+ ` required config keys: ${channel.requiredKeys.join(', ') || 'none'}`,
197
+ ` missing config keys: ${channel.missingRequiredKeys.join(', ') || 'none'}`,
198
+ ` default target keys: ${channel.defaultTargetKeys.join(', ') || 'not required'}`,
199
+ ` configured target keys: ${channel.configuredDefaultTargetKeys.join(', ') || 'none'}`,
200
+ ` next: ${channel.nextStep}`,
201
+ ' policy: this command never prints secret values and never sends messages',
202
+ ].join('\n'));
203
+ }
204
+
205
+ function formatChannelAccounts(body: unknown): string {
206
+ const root = isRecord(body) ? body : {};
207
+ const accounts = readRecordArray(root, 'accounts');
208
+ const lines = [
209
+ 'Channel Accounts',
210
+ ` accounts: ${accounts.length}`,
211
+ ' policy: read-only account posture; secret values are never shown',
212
+ '',
213
+ ];
214
+ if (accounts.length === 0) return [...lines, ' No channel accounts reported by connected services.'].join('\n');
215
+ for (const account of accounts.slice(0, 20)) {
216
+ const surface = readString(account, 'surface', 'unknown');
217
+ const accountId = readString(account, 'accountId', '');
218
+ const authState = readString(account, 'authState', readString(account, 'state', 'unknown'));
219
+ const configured = readBoolean(account, 'configured') ? 'configured' : 'not-configured';
220
+ const linked = readBoolean(account, 'linked') ? 'linked' : 'not-linked';
221
+ const secrets = readRecordArray(account, 'secrets')
222
+ .map((secret) => `${readString(secret, 'field', 'secret')}:${readString(secret, 'source', 'configured')}`)
223
+ .join(', ') || 'none';
224
+ lines.push(` ${surface}${accountId ? `/${accountId}` : ''}: ${configured}; ${linked}; auth=${authState}; secret refs=${secrets}`);
225
+ }
226
+ if (accounts.length > 20) lines.push(` ${accounts.length - 20} more account(s) omitted.`);
227
+ return lines.join('\n');
228
+ }
229
+
230
+ function formatChannelPolicies(body: unknown): string {
231
+ const root = isRecord(body) ? body : {};
232
+ const policies = readRecordArray(root, 'policies');
233
+ const lines = [
234
+ 'Channel Policies',
235
+ ` policies: ${policies.length}`,
236
+ ' policy: read-only policy posture; use exact confirmed commands for changes',
237
+ '',
238
+ ];
239
+ if (policies.length === 0) return [...lines, ' No channel policies reported by connected services.'].join('\n');
240
+ for (const policy of policies.slice(0, 20)) {
241
+ const surface = readString(policy, 'surface', 'unknown');
242
+ const direct = readBoolean(policy, 'allowDirectMessages') ? 'direct=yes' : 'direct=no';
243
+ const users = Array.isArray(policy.allowlistUserIds) ? policy.allowlistUserIds.length : 0;
244
+ const groups = Array.isArray(policy.allowlistGroupIds) ? policy.allowlistGroupIds.length : 0;
245
+ const groupPolicies = readRecordArray(policy, 'groupPolicies').length;
246
+ lines.push(` ${surface}: ${direct}; allowlist users=${users}; groups=${groups}; group policies=${groupPolicies}`);
247
+ }
248
+ if (policies.length > 20) lines.push(` ${policies.length - 20} more policy record(s) omitted.`);
249
+ return lines.join('\n');
250
+ }
251
+
252
+ function formatChannelStatus(body: unknown): string {
253
+ const root = isRecord(body) ? body : {};
254
+ const channels = readRecordArray(root, 'channels');
255
+ const lines = [
256
+ 'Connected Channel Status',
257
+ ` channels: ${channels.length}`,
258
+ ' policy: read-only connected-service status',
259
+ '',
260
+ ];
261
+ if (channels.length === 0) return [...lines, ' No connected channel status reported.'].join('\n');
262
+ for (const channel of channels.slice(0, 20)) {
263
+ const surface = readString(channel, 'surface', 'unknown');
264
+ const state = readString(channel, 'state', readString(channel, 'status', 'unknown'));
265
+ const enabled = readBoolean(channel, 'enabled') ? 'enabled' : 'disabled';
266
+ const ready = readBoolean(channel, 'ready') ? 'ready' : 'not-ready';
267
+ lines.push(` ${surface}: ${enabled}; ${ready}; state=${state}`);
268
+ }
269
+ if (channels.length > 20) lines.push(` ${channels.length - 20} more channel(s) omitted.`);
270
+ return lines.join('\n');
271
+ }
272
+
273
+ function formatChannelDoctor(surface: string, body: unknown): string {
274
+ const root = isRecord(body) ? body : {};
275
+ const checks = readRecordArray(root, 'checks');
276
+ const repairActions = readRecordArray(root, 'repairActions');
277
+ const lines = [
278
+ `Channel Doctor: ${readString(root, 'surface', surface)}`,
279
+ ` checks: ${checks.length}`,
280
+ ` repair actions: ${repairActions.length}`,
281
+ ' policy: read-only doctor report; repair actions are not run here',
282
+ '',
283
+ ];
284
+ if (checks.length === 0) lines.push(' No doctor checks reported.');
285
+ for (const check of checks.slice(0, 20)) {
286
+ lines.push(` ${readString(check, 'id', 'check')}: ${readString(check, 'status', 'unknown')}`);
287
+ }
288
+ if (repairActions.length > 0) {
289
+ lines.push('', ' Available repair action ids:');
290
+ for (const action of repairActions.slice(0, 12)) lines.push(` - ${readString(action, 'id', 'action')}`);
291
+ }
292
+ return lines.join('\n');
293
+ }
294
+
295
+ function formatChannelSetup(surface: string, body: unknown): string {
296
+ const root = isRecord(body) ? body : {};
297
+ const fields = readRecordArray(root, 'fields');
298
+ const secretTargets = readRecordArray(root, 'secretTargets');
299
+ const lines = [
300
+ `Channel Setup Schema: ${readString(root, 'surface', surface)}`,
301
+ ` version: ${typeof root.version === 'number' ? root.version : 'unknown'}`,
302
+ ` fields: ${fields.length}`,
303
+ ` secret targets: ${secretTargets.length}`,
304
+ ' policy: read-only setup schema; no credentials or values are printed',
305
+ '',
306
+ ];
307
+ if (fields.length > 0) {
308
+ lines.push(' Fields:');
309
+ for (const field of fields.slice(0, 20)) lines.push(` - ${readString(field, 'id', 'field')}`);
310
+ }
311
+ if (secretTargets.length > 0) {
312
+ lines.push('', ' Secret targets:');
313
+ for (const target of secretTargets.slice(0, 12)) {
314
+ const required = readBoolean(target, 'required') ? 'required' : 'optional';
315
+ lines.push(` - ${readString(target, 'id', 'secret')} (${required})`);
316
+ }
317
+ }
318
+ return lines.join('\n');
319
+ }
320
+
321
+ async function printReadOnlyChannelRoute(
322
+ context: CommandContext,
323
+ title: string,
324
+ route: string,
325
+ format: (body: unknown) => string,
326
+ ): Promise<void> {
327
+ const result = await fetchChannelRoute(context, route);
328
+ if (!result.ok) {
329
+ context.print(formatChannelRouteFailure(title, result));
330
+ return;
331
+ }
332
+ context.print(format(result.body));
333
+ }
334
+
4
335
  export function registerChannelsRuntimeCommands(registry: CommandRegistry): void {
5
336
  registry.register({
6
337
  name: 'channels',
7
338
  aliases: ['channel'],
8
339
  description: 'Inspect Agent channel readiness without sending messages',
9
- usage: '[list|readiness]',
10
- argsHint: 'list|readiness',
11
- handler(_args, ctx) {
340
+ usage: '[list|readiness|ready|attention|show <id>|accounts|policies|status|doctor <id>|setup <id>]',
341
+ argsHint: 'list|readiness|ready|attention|show|accounts|policies|status|doctor|setup',
342
+ async handler(args, ctx) {
12
343
  const channels = buildAgentWorkspaceChannels(ctx);
13
- const ready = channels.filter((channel) => channel.ready);
14
- const enabled = channels.filter((channel) => channel.enabled);
15
- const needsTarget = channels.filter((channel) => channel.setupState === 'needs-target');
16
- const needsConfig = channels.filter((channel) => channel.setupState === 'needs-config');
17
- const lines: string[] = [
18
- 'Channel Readiness',
19
- ` ready: ${ready.length}/${channels.length}`,
20
- ` enabled: ${enabled.length}/${channels.length}`,
21
- ` needs target: ${needsTarget.length}`,
22
- ` needs config: ${needsConfig.length}`,
23
- ' policy: read-only inspection; sends require explicit user action and Agent policy',
24
- '',
25
- ...channels.map((channel) => {
26
- const missing = channel.missingRequiredKeys.length > 0
27
- ? ` missing=${channel.missingRequiredKeys.join('|')}`
28
- : '';
29
- const target = channel.configuredDefaultTargetKeys.length > 0
30
- ? ` target=${channel.configuredDefaultTargetKeys.join('|')}`
31
- : channel.defaultTargetKeys.length > 0
32
- ? ` target=missing(${channel.defaultTargetKeys.join('|')})`
33
- : ' target=not-required';
34
- return [
35
- ` ${channel.label}: ${channel.setupState}`,
36
- `ready=${channel.ready ? 'yes' : 'no'}`,
37
- `delivery=${channel.delivery}`,
38
- `risk=${channel.risk}`,
39
- target,
40
- missing,
41
- ].join(' ');
42
- }),
43
- ];
44
- ctx.print(lines.join('\n'));
344
+ const subcommand = (args[0] ?? 'readiness').trim().toLowerCase();
345
+
346
+ if (subcommand === 'list' || subcommand === 'readiness') {
347
+ printChannelSummary(ctx.print, channels, 'all');
348
+ return;
349
+ }
350
+
351
+ if (subcommand === 'ready') {
352
+ printChannelSummary(ctx.print, channels, 'ready');
353
+ return;
354
+ }
355
+
356
+ if (subcommand === 'attention' || subcommand === 'issues') {
357
+ printChannelSummary(ctx.print, channels, 'attention');
358
+ return;
359
+ }
360
+
361
+ if (subcommand === 'show') {
362
+ const channelId = args[1]?.trim().toLowerCase();
363
+ if (!channelId) {
364
+ ctx.print('Usage: /channels show <id>');
365
+ return;
366
+ }
367
+ const channel = channels.find((entry) => entry.id.toLowerCase() === channelId || entry.label.toLowerCase() === channelId);
368
+ if (!channel) {
369
+ ctx.print(`Unknown channel: ${channelId}\nUse /channels list to see available channel ids.`);
370
+ return;
371
+ }
372
+ printChannelDetail(ctx.print, channel);
373
+ return;
374
+ }
375
+
376
+ if (subcommand === 'accounts') {
377
+ await printReadOnlyChannelRoute(ctx, 'Channel accounts', '/api/channels/accounts', formatChannelAccounts);
378
+ return;
379
+ }
380
+
381
+ if (subcommand === 'policies') {
382
+ await printReadOnlyChannelRoute(ctx, 'Channel policies', '/api/channels/policies', formatChannelPolicies);
383
+ return;
384
+ }
385
+
386
+ if (subcommand === 'status') {
387
+ await printReadOnlyChannelRoute(ctx, 'Channel status', '/api/channels/status', formatChannelStatus);
388
+ return;
389
+ }
390
+
391
+ if (subcommand === 'doctor' || subcommand === 'setup') {
392
+ const channelId = args[1]?.trim().toLowerCase();
393
+ if (!channelId) {
394
+ ctx.print(`Usage: /channels ${subcommand} <id>`);
395
+ return;
396
+ }
397
+ const encoded = encodeURIComponent(channelId);
398
+ if (subcommand === 'doctor') {
399
+ await printReadOnlyChannelRoute(ctx, 'Channel doctor', `/api/channels/doctor/${encoded}`, (body) => formatChannelDoctor(channelId, body));
400
+ return;
401
+ }
402
+ await printReadOnlyChannelRoute(ctx, 'Channel setup', `/api/channels/setup/${encoded}`, (body) => formatChannelSetup(channelId, body));
403
+ return;
404
+ }
405
+
406
+ ctx.print('Usage: /channels [list|readiness|ready|attention|show <id>|accounts|policies|status|doctor <id>|setup <id>]');
45
407
  },
46
408
  });
47
409
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import type { CommandRegistry } from '../command-registry.ts';
4
- import { requirePanelManager, requireShellPaths } from './runtime-services.ts';
4
+ import { requireShellPaths } from './runtime-services.ts';
5
5
  import { requireYesFlag, stripYesFlag } from './confirmation.ts';
6
6
 
7
7
  interface VoiceBundle {
@@ -29,12 +29,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
29
29
  async handler(args, ctx) {
30
30
  const sub = (args[0] ?? 'matrix').toLowerCase();
31
31
  if (sub === 'open' || sub === 'panel') {
32
- if (ctx.showPanel) ctx.showPanel('approval');
33
- else {
34
- const panelManager = requirePanelManager(ctx);
35
- panelManager.open('approval');
36
- panelManager.show();
37
- }
32
+ ctx.print('Approval panels are not part of the Agent workspace. Use /approval matrix.');
38
33
  return;
39
34
  }
40
35
  const matrix = [
@@ -64,11 +59,11 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
64
59
  ctx.print([
65
60
  `Approval Review: ${entry[0]}`,
66
61
  ` ${entry[1]}`,
67
- ' Related workspaces: /security, /policy preflight, /trust, /mcp',
62
+ ' Related workspaces: /security, /trust, /mcp',
68
63
  ].join('\n'));
69
64
  return;
70
65
  }
71
- ctx.print('Usage: /approval [open|matrix|review <kind>]');
66
+ ctx.print('Usage: /approval [matrix|review <kind>]');
72
67
  },
73
68
  });
74
69
 
@@ -7,7 +7,6 @@ import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
7
7
  import { getSettingsControlPlaneSnapshot } from '@/runtime/index.ts';
8
8
  import { checkRecoveryFile, readLastSessionPointer } from '@/runtime/index.ts';
9
9
  import {
10
- openCommandPanel,
11
10
  requireOperatorClient,
12
11
  requireProviderApi,
13
12
  requireReadModels,
@@ -22,16 +21,37 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
22
21
  name: 'health',
23
22
  aliases: ['doctor'],
24
23
  description: 'Health workspace for startup posture, service readiness, provider health, and Agent continuity',
25
- usage: '[open|review|setup|services|provider|accounts|auth|settings|remote|mcp|continuity|maintenance|repair [domain]]',
24
+ usage: '[review|setup|services|provider|accounts|auth|settings|remote|mcp|continuity|maintenance|repair [domain]]',
26
25
  async handler(args, ctx) {
27
26
  const sub = (args[0] ?? 'review').toLowerCase();
28
- const readModels = requireReadModels(ctx);
29
27
 
30
- if (sub === 'open' || sub === 'panel' || sub === 'provider') {
31
- openCommandPanel(ctx, 'provider-health');
28
+ if (sub === 'open' || sub === 'panel') {
29
+ ctx.print('Health panels are not part of the Agent workspace. Use /health review.');
32
30
  return;
33
31
  }
34
32
 
33
+ if (sub === 'provider') {
34
+ const providerApi = requireProviderApi(ctx);
35
+ const currentModel = await providerApi.getCurrentModel().catch(() => null);
36
+ const accounts = await requireOperatorClient(ctx).providers.accountSnapshot();
37
+ ctx.print([
38
+ 'Health Review: Provider',
39
+ ` selected model: ${currentModel?.registryKey ?? 'unknown'}`,
40
+ ` providers: ${providerApi.listProviderIds().length}`,
41
+ ` configured accounts: ${accounts.configuredCount}`,
42
+ ` account issues: ${accounts.issueCount}`,
43
+ ...(accounts.issueCount > 0
44
+ ? accounts.providers.flatMap((provider) => provider.issues.map((issue) => ` ${provider.providerId}: ${issue}`))
45
+ : [' no provider account issues detected']),
46
+ ' next: /model',
47
+ ' next: /provider',
48
+ ' next: /accounts review',
49
+ ].join('\n'));
50
+ return;
51
+ }
52
+
53
+ const readModels = requireReadModels(ctx);
54
+
35
55
  if (sub === 'services') {
36
56
  const registry = requireServiceRegistry(ctx);
37
57
  const all = registry.getAll();
@@ -318,7 +338,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
318
338
  ...(snapshot.serviceIssues.length > 0 ? ['', ...snapshot.serviceIssues.map((issue) => ` service: ${issue}`)] : []),
319
339
  '',
320
340
  'Next steps:',
321
- ' /health open',
341
+ ' /health review',
322
342
  ' /health services',
323
343
  ' /health accounts',
324
344
  ' /health auth',
@@ -137,8 +137,8 @@ export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
137
137
  name: 'mcp',
138
138
  aliases: [],
139
139
  description: 'Manage MCP servers and their tools',
140
- usage: '[add|remove|reload|config|review|tools [<server>]|auth-review|repair [server]]',
141
- argsHint: '[review|tools|config|add --yes|remove --yes]',
140
+ usage: '[servers|review|tools [<server>]|config|add|remove|reload|auth-review|repair [server]]',
141
+ argsHint: '[servers|review|tools|config|add --yes|remove --yes]',
142
142
  async handler(args, ctx) {
143
143
  const mcpApi = requireMcpApi(ctx);
144
144
  const listServerSecurity = () => mcpApi.listServerSecurity();
@@ -163,6 +163,25 @@ export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
163
163
  ].join('\n'));
164
164
  return;
165
165
  }
166
+ if (subcommand === 'servers') {
167
+ const servers = listServerSecurity();
168
+ if (servers.length === 0) {
169
+ ctx.print(
170
+ 'No MCP servers configured.\n'
171
+ + 'Add servers to one of these locations (scanned in order):\n'
172
+ + ' ~/.config/mcp/mcp.json (global XDG)\n'
173
+ + ' ~/.mcp/mcp.json (global dotdir)\n'
174
+ + ' ~/.config/claude/claude_desktop_config.json (Claude Desktop)\n'
175
+ + ' .mcp/mcp.json (project-local)\n'
176
+ + ' .goodvibes/mcp.json (goodvibes project)\n'
177
+ + '\nAdd one from inside Agent with explicit confirmation:\n'
178
+ + ' /mcp add filesystem npx -y @modelcontextprotocol/server-filesystem . --scope project --role filesystem --yes'
179
+ );
180
+ return;
181
+ }
182
+ ctx.print(formatMcpServerList(servers));
183
+ return;
184
+ }
166
185
  if (subcommand === 'tools') {
167
186
  const filterServer = commandArgs[1];
168
187
  ctx.print('Fetching MCP tool list...');
@@ -434,30 +453,36 @@ export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
434
453
  return;
435
454
  }
436
455
 
437
- const connected = servers.filter(s => s.connected);
438
- const disconnected = servers.filter(s => !s.connected);
439
- const lines: string[] = [`MCP Servers (${connected.length}/${servers.length} connected):`];
440
- for (const s of servers) {
441
- const pathScope = s.allowedPaths.length > 0 ? ` paths=${s.allowedPaths.length}` : '';
442
- const hostScope = s.allowedHosts.length > 0 ? ` hosts=${s.allowedHosts.length}` : '';
443
- const freshness = ` freshness=${s.schemaFreshness}`;
444
- const quarantine = s.schemaFreshness === 'quarantined' ? ` quarantine=${s.quarantineReason ?? 'unknown'}` : '';
445
- lines.push(` ${s.connected ? '[connected] ' : '[disconnected]'} ${s.name} trust=${s.trustMode} role=${s.role}${freshness}${quarantine}${pathScope}${hostScope}`);
446
- }
447
- if (connected.length > 0) {
448
- lines.push('');
449
- lines.push('Run "/mcp tools" to list all tools, or "/mcp tools <server>" for a specific server.');
450
- lines.push('Run "/mcp" to open the fullscreen MCP workspace, or "/mcp add <name> <command> [args...] [--scope project|global] --yes" to add/update.');
451
- lines.push('Run "/mcp reload --yes" after editing MCP config outside Agent.');
452
- lines.push('Run "/mcp trust <server> <mode> --yes" to change trust mode, or "/mcp role <server> <role> --yes" to change its coherence role.');
453
- lines.push('Run "/mcp quarantine <server> [detail] --yes" to block a server, or "/mcp quarantine <server> approve [operatorId] --yes" to approve a temporary override.');
454
- lines.push('Use /settings → MCP to explicitly enable allow-all for a server.');
455
- }
456
- if (disconnected.length > 0) {
457
- lines.push('');
458
- lines.push(`${disconnected.length} server(s) failed to connect. Check server command and args in your config.`);
459
- }
460
- ctx.print(lines.join('\n'));
456
+ ctx.print(formatMcpServerList(servers));
461
457
  },
462
458
  });
463
459
  }
460
+
461
+ type McpSecurityServer = ReturnType<NonNullable<NonNullable<CommandContext['clients']>['mcpApi']>['listServerSecurity']>[number];
462
+
463
+ function formatMcpServerList(servers: readonly McpSecurityServer[]): string {
464
+ const connected = servers.filter(s => s.connected);
465
+ const disconnected = servers.filter(s => !s.connected);
466
+ const lines: string[] = [`MCP Servers (${connected.length}/${servers.length} connected):`];
467
+ for (const s of servers) {
468
+ const pathScope = s.allowedPaths.length > 0 ? ` paths=${s.allowedPaths.length}` : '';
469
+ const hostScope = s.allowedHosts.length > 0 ? ` hosts=${s.allowedHosts.length}` : '';
470
+ const freshness = ` freshness=${s.schemaFreshness}`;
471
+ const quarantine = s.schemaFreshness === 'quarantined' ? ` quarantine=${s.quarantineReason ?? 'unknown'}` : '';
472
+ lines.push(` ${s.connected ? '[connected] ' : '[disconnected]'} ${s.name} trust=${s.trustMode} role=${s.role}${freshness}${quarantine}${pathScope}${hostScope}`);
473
+ }
474
+ if (connected.length > 0) {
475
+ lines.push('');
476
+ lines.push('Run "/mcp tools" to list all tools, or "/mcp tools <server>" for a specific server.');
477
+ lines.push('Run "/mcp" to open the fullscreen MCP workspace, or "/mcp add <name> <command> [args...] [--scope project|global] --yes" to add/update.');
478
+ lines.push('Run "/mcp reload --yes" after editing MCP config outside Agent.');
479
+ lines.push('Run "/mcp trust <server> <mode> --yes" to change trust mode, or "/mcp role <server> <role> --yes" to change its coherence role.');
480
+ lines.push('Run "/mcp quarantine <server> [detail] --yes" to block a server, or "/mcp quarantine <server> approve [operatorId] --yes" to approve a temporary override.');
481
+ lines.push('Use /settings -> MCP to explicitly enable allow-all for a server.');
482
+ }
483
+ if (disconnected.length > 0) {
484
+ lines.push('');
485
+ lines.push(`${disconnected.length} server(s) failed to connect. Check server command and args in your config.`);
486
+ }
487
+ return lines.join('\n');
488
+ }