@pellux/goodvibes-agent 1.5.5 → 1.5.6

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 (165) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/dist/package/main.js +94130 -79335
  4. package/docs/tools-and-commands.md +2 -1
  5. package/package.json +6 -3
  6. package/src/agent/autonomy-schedule-format.ts +1 -1
  7. package/src/agent/autonomy-schedule.ts +3 -3
  8. package/src/agent/calendar/calendar-oauth-service.ts +324 -0
  9. package/src/agent/calendar-subscription-registry.ts +497 -0
  10. package/src/agent/email/email-service.ts +57 -0
  11. package/src/agent/email/smtp-client.ts +30 -10
  12. package/src/agent/memory-prompt.ts +143 -12
  13. package/src/agent/operator-actions.ts +26 -22
  14. package/src/agent/prompt-context-receipts.ts +45 -18
  15. package/src/agent/reminder-schedule-format.ts +1 -1
  16. package/src/agent/reminder-schedule.ts +3 -3
  17. package/src/agent/routine-schedule-format.ts +2 -2
  18. package/src/agent/routine-schedule-promotion.ts +5 -5
  19. package/src/agent/routine-schedule-receipts.ts +2 -2
  20. package/src/agent/schedule-edit-format.ts +1 -1
  21. package/src/agent/schedule-edit.ts +5 -5
  22. package/src/agent/session-registration.ts +281 -0
  23. package/src/agent/vibe-file.ts +50 -0
  24. package/src/cli/local-library-command-shared.ts +146 -0
  25. package/src/cli/management.ts +2 -1
  26. package/src/cli/personas-command.ts +252 -0
  27. package/src/cli/resume-relaunch-notice.ts +151 -0
  28. package/src/cli/routines-command.ts +25 -2
  29. package/src/cli/service-posture.ts +3 -2
  30. package/src/cli/skill-bundle-command.ts +175 -0
  31. package/src/cli/skills-command.ts +309 -0
  32. package/src/cli/status.ts +43 -7
  33. package/src/cli/tui-startup.ts +24 -1
  34. package/src/config/agent-settings-policy.ts +0 -1
  35. package/src/config/secret-config.ts +4 -0
  36. package/src/core/conversation-rendering.ts +20 -11
  37. package/src/core/focus-tracker.ts +41 -0
  38. package/src/core/system-message-noise.ts +108 -0
  39. package/src/core/system-message-router.ts +54 -1
  40. package/src/core/thinking-overlay.ts +83 -0
  41. package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
  42. package/src/input/agent-workspace-access-command-editors.ts +108 -128
  43. package/src/input/agent-workspace-activation.ts +15 -0
  44. package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
  45. package/src/input/agent-workspace-basic-command-editors.ts +433 -564
  46. package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
  47. package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
  48. package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
  49. package/src/input/agent-workspace-categories.ts +7 -2
  50. package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
  51. package/src/input/agent-workspace-channel-command-editors.ts +42 -46
  52. package/src/input/agent-workspace-command-editor-engine.ts +133 -0
  53. package/src/input/agent-workspace-command-editor.ts +4 -0
  54. package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
  55. package/src/input/agent-workspace-email-connect-editor.ts +162 -0
  56. package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
  57. package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
  58. package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
  59. package/src/input/agent-workspace-library-command-editors.ts +16 -2
  60. package/src/input/agent-workspace-live-counters.ts +55 -0
  61. package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
  62. package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
  63. package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
  64. package/src/input/agent-workspace-media-command-editors.ts +10 -8
  65. package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
  66. package/src/input/agent-workspace-memory-command-editors.ts +116 -141
  67. package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
  68. package/src/input/agent-workspace-operations-command-editors.ts +150 -162
  69. package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
  70. package/src/input/agent-workspace-provider-command-editors.ts +58 -68
  71. package/src/input/agent-workspace-search.ts +8 -1
  72. package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
  73. package/src/input/agent-workspace-session-command-editors.ts +160 -195
  74. package/src/input/agent-workspace-settings.ts +40 -1
  75. package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
  76. package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
  77. package/src/input/agent-workspace-snapshot-builders.ts +431 -0
  78. package/src/input/agent-workspace-snapshot-config.ts +43 -0
  79. package/src/input/agent-workspace-snapshot.ts +198 -432
  80. package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
  81. package/src/input/agent-workspace-task-command-editors.ts +27 -28
  82. package/src/input/agent-workspace-types.ts +35 -1
  83. package/src/input/agent-workspace.ts +5 -14
  84. package/src/input/command-registry.ts +23 -1
  85. package/src/input/commands/calendar-connect-runtime.ts +226 -0
  86. package/src/input/commands/calendar-runtime.ts +110 -7
  87. package/src/input/commands/calendar-subscription-runtime.ts +225 -0
  88. package/src/input/commands/email-runtime.ts +100 -40
  89. package/src/input/commands/knowledge.ts +1 -1
  90. package/src/input/commands/network-scan-runtime.ts +75 -0
  91. package/src/input/commands/operator-actions-runtime.ts +9 -6
  92. package/src/input/commands/personas-runtime.ts +1 -1
  93. package/src/input/commands/schedule-runtime.ts +4 -4
  94. package/src/input/commands/session-content.ts +13 -1
  95. package/src/input/commands/session-workflow.ts +15 -20
  96. package/src/input/commands/session.ts +3 -1
  97. package/src/input/commands/shell-core.ts +20 -6
  98. package/src/input/commands.ts +2 -2
  99. package/src/input/delete-key-policy.ts +46 -0
  100. package/src/input/feed-context-factory.ts +10 -0
  101. package/src/input/handler-feed.ts +87 -0
  102. package/src/input/handler-modal-routes.ts +6 -1
  103. package/src/input/handler.ts +5 -0
  104. package/src/input/panel-paste-flood-guard.ts +94 -0
  105. package/src/input/settings-modal-types.ts +5 -3
  106. package/src/input/settings-modal.ts +21 -0
  107. package/src/main.ts +36 -36
  108. package/src/permissions/approval-posture.ts +141 -0
  109. package/src/renderer/agent-workspace-context-lines.ts +10 -2
  110. package/src/renderer/compositor.ts +27 -4
  111. package/src/renderer/diff.ts +61 -18
  112. package/src/renderer/fullscreen-primitives.ts +37 -18
  113. package/src/renderer/markdown.ts +20 -10
  114. package/src/renderer/modal-factory.ts +25 -15
  115. package/src/renderer/overlay-box.ts +23 -12
  116. package/src/renderer/process-indicator.ts +8 -3
  117. package/src/renderer/prompt-content-width.ts +16 -0
  118. package/src/renderer/settings-modal-helpers.ts +2 -0
  119. package/src/renderer/settings-modal.ts +2 -0
  120. package/src/renderer/startup-theme-probe.ts +35 -0
  121. package/src/renderer/status-glyphs.ts +11 -15
  122. package/src/renderer/system-message.ts +17 -2
  123. package/src/renderer/term-caps.ts +318 -0
  124. package/src/renderer/terminal-bg-probe.ts +373 -0
  125. package/src/renderer/terminal-escapes.ts +24 -0
  126. package/src/renderer/theme-mode-config.ts +87 -0
  127. package/src/renderer/theme.ts +241 -0
  128. package/src/renderer/thinking.ts +12 -3
  129. package/src/renderer/tool-call.ts +7 -3
  130. package/src/renderer/ui-factory.ts +92 -36
  131. package/src/renderer/ui-primitives.ts +33 -93
  132. package/src/runtime/bootstrap-core.ts +15 -0
  133. package/src/runtime/bootstrap-hook-bridge.ts +6 -0
  134. package/src/runtime/bootstrap-shell.ts +2 -0
  135. package/src/runtime/bootstrap.ts +68 -5
  136. package/src/runtime/calendar-boot-refresh.ts +105 -0
  137. package/src/runtime/lan-scan-consent.ts +253 -0
  138. package/src/runtime/services.ts +127 -2
  139. package/src/runtime/session-spine-rest-transport.ts +160 -0
  140. package/src/runtime/terminal-output-guard.ts +6 -1
  141. package/src/runtime/ui-services.ts +3 -0
  142. package/src/shell/agent-workspace-fullscreen.ts +5 -0
  143. package/src/shell/terminal-focus-mode.ts +120 -0
  144. package/src/shell/ui-openers.ts +13 -4
  145. package/src/tools/agent-harness-autonomy-intake.ts +5 -5
  146. package/src/tools/agent-harness-autonomy-queue.ts +1 -1
  147. package/src/tools/agent-harness-background-processes.ts +2 -1
  148. package/src/tools/agent-harness-metadata.ts +16 -8
  149. package/src/tools/agent-harness-operator-methods.ts +44 -8
  150. package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
  151. package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
  152. package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
  153. package/src/tools/agent-harness-personal-ops-types.ts +6 -0
  154. package/src/tools/agent-harness-prompt-context.ts +26 -12
  155. package/src/tools/agent-harness-workspace-actions.ts +4 -0
  156. package/src/tools/agent-local-registry-args.ts +117 -0
  157. package/src/tools/agent-local-registry-memory.ts +227 -0
  158. package/src/tools/agent-local-registry-tool.ts +19 -237
  159. package/src/tools/agent-operator-briefing-tool.ts +2 -2
  160. package/src/tools/agent-operator-method-tool.ts +13 -0
  161. package/src/tools/agent-policy-explanation.ts +39 -4
  162. package/src/tools/agent-schedule-tool.ts +5 -5
  163. package/src/utils/terminal-width.ts +98 -1
  164. package/src/version.ts +1 -1
  165. package/src/cli/local-library-command.ts +0 -825
@@ -1,9 +1,51 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { AgentCalendarRegistry } from '../../agent/calendar-registry.ts';
3
3
  import { parseIcs } from '../../agent/ics-calendar.ts';
4
+ import { parseIcs as parseIcsHonest } from '@pellux/goodvibes-sdk/platform/calendar';
4
5
  import type { CommandContext, CommandRegistry } from '../command-registry.ts';
5
6
  import { parseAgentLocalLibraryArgs } from './agent-local-library-args.ts';
6
7
  import { requireShellPaths } from './runtime-services.ts';
8
+ import {
9
+ CALENDAR_SUBSCRIPTION_VERBS,
10
+ renderSubscribedSection,
11
+ runCalendarSubscriptionCommand,
12
+ subscriptionRegistryForRead,
13
+ } from './calendar-subscription-runtime.ts';
14
+ import {
15
+ fetchProviderUpcoming,
16
+ runCalendarAccounts,
17
+ runCalendarConnect,
18
+ runCalendarDisconnect,
19
+ sourceLabel,
20
+ } from './calendar-connect-runtime.ts';
21
+
22
+ /** Today's date as 'YYYY-MM-DD' (UTC), for windowed occurrence expansion. */
23
+ function todayIsoDate(): string {
24
+ return new Date().toISOString().slice(0, 10);
25
+ }
26
+
27
+ function addDaysIsoDate(days: number): string {
28
+ return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
29
+ }
30
+
31
+ /** Append the merged, source-labeled subscribed section to a local-events block. */
32
+ function withSubscribedSection(localBlock: string, subscribed: string): string {
33
+ return subscribed ? `${localBlock}\n\n${subscribed}` : localBlock;
34
+ }
35
+
36
+ /**
37
+ * F7b: the old unknown-subcommand usage line was one 13-verb run-on
38
+ * ("list|upcoming ...|connect ...|disconnect ...|accounts|import ...|export
39
+ * ...|add ...|delete ...|subscribe ...|unsubscribe ...|subscriptions|refresh
40
+ * ..."), unreadable at a glance. Grouped by what each verb is FOR instead.
41
+ */
42
+ const CALENDAR_USAGE_MESSAGE = [
43
+ 'Usage: /calendar <command>',
44
+ ' Viewing list | upcoming [--days N]',
45
+ ' Connecting connect <google|outlook> [--device] | disconnect <google|outlook> | accounts',
46
+ ' Subscriptions subscribe <ics-url> [--name N] [--every MIN] [--yes] | unsubscribe <name> --yes | subscriptions | refresh [name]',
47
+ ' Local events add --title <title> --start <ISO> --yes | delete <id> --yes | import <path> [--yes] | export [--dest <path>] [--yes]',
48
+ ].join('\n');
7
49
 
8
50
  const CALENDAR_VALUE_FLAGS = ['title', 'start', 'end', 'location', 'notes', 'dest', 'path', 'days'] as const;
9
51
 
@@ -43,15 +85,40 @@ function renderEventList(events: readonly Parameters<typeof formatEventLine>[0][
43
85
  ].join('\n');
44
86
  }
45
87
 
46
- export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandContext): void {
88
+ export async function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandContext): Promise<void> {
47
89
  const sub = (args[0] ?? 'list').toLowerCase();
90
+
91
+ // Connected-provider verbs (OAuth). Handled before the local-store registry so a
92
+ // connect/accounts call never touches the local .ics file.
93
+ if (sub === 'connect' || sub === 'disconnect' || sub === 'accounts') {
94
+ try {
95
+ if (sub === 'connect') await runCalendarConnect(args, ctx);
96
+ else if (sub === 'disconnect') await runCalendarDisconnect(args, ctx);
97
+ else await runCalendarAccounts(ctx);
98
+ } catch (error) {
99
+ printError(ctx, error);
100
+ }
101
+ return;
102
+ }
103
+
48
104
  const registry = registryFromContext(ctx);
105
+ // A read-only subscription registry merges subscribed feed events into the
106
+ // views below. Tolerant of a missing secret manager (seeds/occurrences read
107
+ // only the on-disk cache), so a failure to build it never breaks local views.
108
+ let subs: ReturnType<typeof subscriptionRegistryForRead> | null = null;
109
+ try {
110
+ subs = subscriptionRegistryForRead(ctx);
111
+ } catch {
112
+ subs = null;
113
+ }
49
114
 
50
115
  try {
51
116
  // list / upcoming
52
117
  if (sub === 'list' || sub === 'all') {
53
118
  const snap = registry.snapshot();
54
- ctx.print(renderEventList([...snap.events], snap.path));
119
+ const local = renderEventList([...snap.events], snap.path);
120
+ const subscribed = subs ? renderSubscribedSection(subs.seeds()) : '';
121
+ ctx.print(withSubscribedSection(local, subscribed));
55
122
  return;
56
123
  }
57
124
 
@@ -60,7 +127,24 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
60
127
  const days = parsed.flags.has('days') ? Math.max(1, parseInt(parsed.flags.get('days') ?? '7', 10) || 7) : 7;
61
128
  const snap = registry.snapshot();
62
129
  const events = registry.upcoming(days);
63
- ctx.print(renderEventList([...events], snap.path));
130
+ const local = renderEventList([...events], snap.path);
131
+ // A9's subscribed-feed section (ics-feed source), merged read-only.
132
+ const subscribed = subs ? renderSubscribedSection(subs.occurrencesInWindow(todayIsoDate(), addDaysIsoDate(days))) : '';
133
+ // A10: connected-provider events (google-api / microsoft-graph), best-effort.
134
+ const now = new Date();
135
+ const to = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
136
+ const { events: providerEvents, notes } = await fetchProviderUpcoming(ctx, {
137
+ timeMin: now.toISOString(),
138
+ timeMax: to.toISOString(),
139
+ });
140
+ const providerLines = providerEvents.map((e) =>
141
+ ` [${sourceLabel(e.source)}] ${e.start.value.slice(0, 16).replace('T', ' ')} ${e.summary}${e.calendarLabel ? ` (${e.calendarLabel})` : ''}`,
142
+ );
143
+ const providerBlock = providerEvents.length > 0
144
+ ? [`Connected calendars (${providerEvents.length})`, ...providerLines].join('\n')
145
+ : '';
146
+ const notesBlock = notes.length > 0 ? ['Notes', ...notes].join('\n') : '';
147
+ ctx.print([withSubscribedSection(local, subscribed), providerBlock, notesBlock].filter(Boolean).join('\n\n'));
64
148
  return;
65
149
  }
66
150
 
@@ -90,12 +174,21 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
90
174
  return;
91
175
  }
92
176
  const { imported, skipped, secretSkipped } = registry.importIcs(content);
177
+ // Honest recurrence/skip report from the SDK reader: events the local store
178
+ // could not use, and recurring events whose RRULE this build does not fully
179
+ // expand (imported as a single seed, never silently dropped or mis-expanded).
180
+ const honest = parseIcsHonest(content);
181
+ const unsupportedRecurring = honest.events.filter((e) => e.recurrence?.expansion === 'unsupported');
93
182
  ctx.print([
94
183
  `Imported ${imported.length} event${imported.length === 1 ? '' : 's'} from ${filePath}`,
95
184
  ...imported.slice(0, 10).map((e) => ` ${e.id} ${e.start} ${e.title}`),
96
185
  imported.length > 10 ? ` ... and ${imported.length - 10} more` : '',
97
186
  skipped > 0 ? `Skipped ${skipped} event${skipped === 1 ? '' : 's'} that already exist.` : '',
98
187
  secretSkipped > 0 ? `Skipped ${secretSkipped} event${secretSkipped === 1 ? '' : 's'} for safety.` : '',
188
+ honest.skipped.length > 0 ? `Could not parse ${honest.skipped.length} entr${honest.skipped.length === 1 ? 'y' : 'ies'}: ${honest.skipped[0]?.message ?? ''}` : '',
189
+ unsupportedRecurring.length > 0
190
+ ? `${unsupportedRecurring.length} recurring event${unsupportedRecurring.length === 1 ? '' : 's'} imported as a single date only (recurrence not fully expanded): ${unsupportedRecurring.slice(0, 3).map((e) => e.summary).join(', ')}${unsupportedRecurring.length > 3 ? ', ...' : ''}`
191
+ : '',
99
192
  ].filter(Boolean).join('\n'));
100
193
  return;
101
194
  }
@@ -169,6 +262,11 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
169
262
  ctx.print('Usage: /calendar delete <id> --yes');
170
263
  return;
171
264
  }
265
+ if (id.startsWith('sub:')) {
266
+ const subName = id.split(':')[1] ?? 'a subscription';
267
+ ctx.print(`Cannot delete ${id}: it is a read-only event from the '${subName}' subscription. Edit it in the source calendar, or run /calendar unsubscribe ${subName} --yes to remove the whole feed.`);
268
+ return;
269
+ }
172
270
  if (!parsed.yes) {
173
271
  const event = registry.get(id);
174
272
  if (!event) {
@@ -189,7 +287,7 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
189
287
  return;
190
288
  }
191
289
 
192
- ctx.print('Usage: /calendar [list|upcoming [--days N]|import <path> [--yes]|export [--dest <path>] [--yes]|add --title <title> --start <ISO> --yes|delete <id> --yes]');
290
+ ctx.print(CALENDAR_USAGE_MESSAGE);
193
291
  } catch (error) {
194
292
  printError(ctx, error);
195
293
  }
@@ -199,11 +297,16 @@ export function registerCalendarRuntimeCommands(registry: CommandRegistry): void
199
297
  registry.register({
200
298
  name: 'calendar',
201
299
  aliases: ['cal'],
202
- description: 'Manage local calendar events',
300
+ description: 'Manage local calendar events, external calendar subscriptions, and connected calendar accounts',
203
301
  hidden: true,
204
- usage: '[list|upcoming [--days N]|import <path> [--yes]|export [--dest <path>] [--yes]|add --title <title> --start <ISO> [--end <ISO>] [--location <loc>] [--notes <notes>] --yes|delete <id> --yes]',
302
+ usage: '[list|upcoming [--days N]|connect <google|outlook> [--device]|disconnect <google|outlook>|accounts|import <path> [--yes]|export [--dest <path>] [--yes]|add --title <title> --start <ISO> [--end <ISO>] [--location <loc>] [--notes <notes>] --yes|delete <id> --yes|subscribe <ics-url> [--name N] [--every MIN] [--yes]|unsubscribe <name> --yes|subscriptions|refresh [name]]',
205
303
  handler: async (args: readonly string[], ctx: CommandContext) => {
206
- runCalendarRuntimeCommand(args, ctx);
304
+ const sub = (args[0] ?? 'list').toLowerCase();
305
+ if (CALENDAR_SUBSCRIPTION_VERBS.has(sub)) {
306
+ await runCalendarSubscriptionCommand(sub, args.slice(1), ctx);
307
+ return;
308
+ }
309
+ await runCalendarRuntimeCommand(args, ctx);
207
310
  },
208
311
  });
209
312
  }
@@ -0,0 +1,225 @@
1
+ import {
2
+ CalendarSubscriptionRegistry,
3
+ createHttpFeedFetcher,
4
+ type SubscribedOccurrence,
5
+ type SubscriptionStatus,
6
+ type SubscriptionSecretStore,
7
+ } from '../../agent/calendar-subscription-registry.ts';
8
+ import type { FeedFetcher } from '@pellux/goodvibes-sdk/platform/calendar';
9
+ import type { CommandContext } from '../command-registry.ts';
10
+ import { parseAgentLocalLibraryArgs } from './agent-local-library-args.ts';
11
+ import { requireShellPaths } from './runtime-services.ts';
12
+
13
+ /**
14
+ * External-calendar SUBSCRIPTION verbs for /calendar — the no-OAuth read path
15
+ * (W4-A9). Subscriptions are iCalendar feeds (Google secret address, Outlook
16
+ * published .ics, or any .ics URL). The parse/RRULE/fetch-status engine is the
17
+ * SDK's platform/calendar; this file is the /calendar command surface over the
18
+ * agent-side CalendarSubscriptionRegistry.
19
+ *
20
+ * A feed URL is secrets-adjacent (a Google secret address grants read access),
21
+ * so it is only ever stored through the secret manager and shown masked; the
22
+ * subscribe verb refuses to run without a secret manager present.
23
+ */
24
+
25
+ const SUBSCRIPTION_VALUE_FLAGS = ['name', 'every'] as const;
26
+ export const CALENDAR_SUBSCRIPTION_VERBS = new Set(['subscribe', 'unsubscribe', 'subscriptions', 'subs', 'refresh']);
27
+
28
+ /** A no-op secret store for the read-only merged view, where no secret is ever read. */
29
+ const READ_ONLY_SECRET_STUB: SubscriptionSecretStore = {
30
+ get: async () => null,
31
+ set: async () => { throw new Error('read-only'); },
32
+ };
33
+
34
+ function secretStoreFrom(ctx: CommandContext): SubscriptionSecretStore | null {
35
+ const sm = ctx.platform.secretsManager;
36
+ if (!sm) return null;
37
+ return sm as unknown as SubscriptionSecretStore;
38
+ }
39
+
40
+ /**
41
+ * Build a registry for a WRITE/network verb — requires a secret manager. Returns
42
+ * null (and the caller prints the honest reason) when none is available.
43
+ */
44
+ export function subscriptionRegistryForWrite(ctx: CommandContext, fetcher?: FeedFetcher): CalendarSubscriptionRegistry | null {
45
+ const secrets = secretStoreFrom(ctx);
46
+ if (!secrets) return null;
47
+ return CalendarSubscriptionRegistry.create(requireShellPaths(ctx), secrets, fetcher ?? createHttpFeedFetcher());
48
+ }
49
+
50
+ /** Build a registry for the READ-ONLY merged view — tolerant of a missing secret manager. */
51
+ export function subscriptionRegistryForRead(ctx: CommandContext): CalendarSubscriptionRegistry {
52
+ const secrets = secretStoreFrom(ctx) ?? READ_ONLY_SECRET_STUB;
53
+ return CalendarSubscriptionRegistry.create(requireShellPaths(ctx), secrets, createHttpFeedFetcher());
54
+ }
55
+
56
+ function parseArgs(args: readonly string[]) {
57
+ return parseAgentLocalLibraryArgs(args, { valueFlags: SUBSCRIPTION_VALUE_FLAGS });
58
+ }
59
+
60
+ function formatHealth(s: SubscriptionStatus): string {
61
+ const base = `${s.health}`;
62
+ return s.detail ? `${base} — ${s.detail}` : base;
63
+ }
64
+
65
+ /** Render the subscribed-events section merged into /calendar list & upcoming. */
66
+ export function renderSubscribedSection(occurrences: readonly SubscribedOccurrence[]): string {
67
+ if (occurrences.length === 0) return '';
68
+ const lines = occurrences.map((o) => {
69
+ const when = o.allDay ? o.start : o.start.slice(0, 16).replace('T', ' ');
70
+ const loc = o.location ? ` at ${o.location}` : '';
71
+ const marker = o.recurrenceNotFullyExpanded ? ' [recurrence not fully expanded]' : '';
72
+ return ` [${o.subscription}] ${when} ${o.title}${loc}${marker} (read-only)`;
73
+ });
74
+ return [`Subscribed calendar events (${occurrences.length})`, ...lines].join('\n');
75
+ }
76
+
77
+ /**
78
+ * Handle a subscription verb. Returns true if it handled the sub-command (so the
79
+ * caller's local-store path is skipped).
80
+ */
81
+ export async function runCalendarSubscriptionCommand(
82
+ sub: string,
83
+ args: readonly string[],
84
+ ctx: CommandContext,
85
+ /** Injectable for tests — production callers pass undefined and get the real HTTP fetcher. */
86
+ fetcher?: FeedFetcher,
87
+ ): Promise<boolean> {
88
+ if (!CALENDAR_SUBSCRIPTION_VERBS.has(sub)) return false;
89
+
90
+ if (sub === 'subscribe') {
91
+ await handleSubscribe(args, ctx, fetcher);
92
+ return true;
93
+ }
94
+ if (sub === 'unsubscribe') {
95
+ await handleUnsubscribe(args, ctx, fetcher);
96
+ return true;
97
+ }
98
+ if (sub === 'subscriptions' || sub === 'subs') {
99
+ await handleList(ctx, fetcher);
100
+ return true;
101
+ }
102
+ if (sub === 'refresh') {
103
+ await handleRefresh(args, ctx, fetcher);
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ function requireRegistry(ctx: CommandContext, fetcher: FeedFetcher | undefined, action: string): CalendarSubscriptionRegistry | null {
110
+ const registry = subscriptionRegistryForWrite(ctx, fetcher);
111
+ if (!registry) {
112
+ ctx.print(`Cannot ${action}: no secret manager is available in this runtime, and feed URLs are stored only as secrets.`);
113
+ return null;
114
+ }
115
+ return registry;
116
+ }
117
+
118
+ async function handleSubscribe(args: readonly string[], ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
119
+ const parsed = parseArgs(args);
120
+ const url = parsed.rest[0] ?? parsed.flags.get('url');
121
+ if (!url) {
122
+ ctx.print('Usage: /calendar subscribe <ics-url> [--name <name>] [--every <minutes>] [--yes]');
123
+ return;
124
+ }
125
+ const requestedName = parsed.flags.get('name')?.trim() || undefined;
126
+ const everyMin = parsed.flags.has('every') ? Math.max(15, parseInt(parsed.flags.get('every') ?? '', 10) || 60) : undefined;
127
+ const refreshIntervalMs = everyMin !== undefined ? everyMin * 60_000 : undefined;
128
+
129
+ const registry = requireRegistry(ctx, fetcher, 'subscribe');
130
+ if (!registry) return;
131
+
132
+ // Consent-at-add preview: validate by fetching and state exactly what will be
133
+ // fetched and how often, before anything is saved.
134
+ if (!parsed.yes) {
135
+ const check = await registry.validate(url, requestedName);
136
+ if (!check.ok) {
137
+ ctx.print([`Subscription preview failed at the ${check.stage} stage.`, ` ${check.detail}`].join('\n'));
138
+ return;
139
+ }
140
+ const intervalMin = Math.round((refreshIntervalMs ?? 60 * 60_000) / 60_000);
141
+ ctx.print([
142
+ 'Calendar subscription preview',
143
+ ` calendar ${check.calendarName ?? '(unnamed feed)'}`,
144
+ ` name ${check.derivedName}`,
145
+ ` events ${check.eventCount} found`,
146
+ ` refresh every ${intervalMin} min (read-only; the feed URL is stored as a secret)`,
147
+ ' This will fetch the calendar from the URL you provided, now and on each refresh.',
148
+ ' rerun with --yes to subscribe',
149
+ ].join('\n'));
150
+ return;
151
+ }
152
+
153
+ const result = await registry.subscribe(url, requestedName, refreshIntervalMs);
154
+ if (!result.ok) {
155
+ ctx.print([`Could not subscribe (${result.stage}).`, ` ${result.detail}`].join('\n'));
156
+ return;
157
+ }
158
+ const intervalMin = Math.round(result.refreshIntervalMs / 60_000);
159
+ ctx.print([
160
+ `Subscribed to '${result.name}'.`,
161
+ ` events ${result.eventCount} loaded`,
162
+ ` refresh every ${intervalMin} min — run /calendar refresh to update now`,
163
+ ' These events are read-only and appear source-labeled in /calendar list.',
164
+ ].join('\n'));
165
+ }
166
+
167
+ async function handleUnsubscribe(args: readonly string[], ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
168
+ const parsed = parseArgs(args);
169
+ const name = parsed.rest.join(' ').trim() || parsed.flags.get('name')?.trim();
170
+ if (!name) {
171
+ ctx.print('Usage: /calendar unsubscribe <name> [--yes]');
172
+ return;
173
+ }
174
+ const registry = requireRegistry(ctx, fetcher, 'unsubscribe');
175
+ if (!registry) return;
176
+ if (!parsed.yes) {
177
+ ctx.print([`Unsubscribe preview`, ` name ${name}`, ' This removes the subscription, its cached events, and its stored feed URL.', ' rerun with --yes to unsubscribe'].join('\n'));
178
+ return;
179
+ }
180
+ const removed = await registry.unsubscribe(name);
181
+ ctx.print(removed ? `Unsubscribed from '${name}'.` : `No subscription named '${name}'.`);
182
+ }
183
+
184
+ async function handleList(ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
185
+ const registry = subscriptionRegistryForWrite(ctx, fetcher) ?? subscriptionRegistryForRead(ctx);
186
+ const statuses = await registry.statuses();
187
+ if (statuses.length === 0) {
188
+ ctx.print(['Calendar subscriptions', ' None. Add one with /calendar subscribe <ics-url> --yes'].join('\n'));
189
+ return;
190
+ }
191
+ ctx.print([
192
+ `Calendar subscriptions (${statuses.length})`,
193
+ ...statuses.map((s) => [
194
+ ` ${s.name}`,
195
+ ` url ${s.maskedUrl}`,
196
+ ` status ${formatHealth(s)}`,
197
+ ` events ${s.eventCount}`,
198
+ ` refresh every ${Math.round(s.refreshIntervalMs / 60_000)} min`,
199
+ ].join('\n')),
200
+ ].join('\n'));
201
+ }
202
+
203
+ async function handleRefresh(args: readonly string[], ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
204
+ const parsed = parseArgs(args);
205
+ const name = parsed.rest.join(' ').trim() || undefined;
206
+ const registry = requireRegistry(ctx, fetcher, 'refresh subscriptions');
207
+ if (!registry) return;
208
+ if (!registry.hasAny()) {
209
+ ctx.print('No calendar subscriptions to refresh. Add one with /calendar subscribe <ics-url> --yes');
210
+ return;
211
+ }
212
+ const outcomes = await registry.refresh(name, { force: true });
213
+ if (outcomes.length === 0) {
214
+ ctx.print(name ? `No subscription named '${name}'.` : 'No calendar subscriptions to refresh.');
215
+ return;
216
+ }
217
+ ctx.print([
218
+ `Refreshed ${outcomes.length} subscription${outcomes.length === 1 ? '' : 's'}`,
219
+ ...outcomes.map((o) => {
220
+ const detail = o.detail ? ` — ${o.detail}` : '';
221
+ const count = o.eventCount !== undefined ? ` (${o.eventCount} events)` : '';
222
+ return ` ${o.name}: ${o.outcome}${count}${detail}`;
223
+ }),
224
+ ].join('\n'));
225
+ }
@@ -61,6 +61,93 @@ function getConfigManager(ctx: CommandContext): { get: (key: string) => unknown
61
61
  return ctx.platform.configManager as unknown as { get: (key: string) => unknown };
62
62
  }
63
63
 
64
+ /** Config manager surface needed to persist one email field. Matches ctx.platform.configManager. */
65
+ export interface EmailConfigManagerLike {
66
+ get: (key: string) => unknown;
67
+ setDynamic: (key: string, value: unknown) => void;
68
+ }
69
+
70
+ /**
71
+ * SecretsManager surface needed to store the raw password. The real
72
+ * ctx.platform.secretsManager (SDK SecretsManager) has more methods;
73
+ * persistSecretBackedConfigValue is called through an opaque cast, matching
74
+ * the existing /email set pattern.
75
+ */
76
+ export interface EmailSecretsManagerLike {
77
+ readonly get: (key: string) => Promise<string | null>;
78
+ }
79
+
80
+ export interface PersistEmailConfigFieldResult {
81
+ readonly ok: boolean;
82
+ readonly error?: string;
83
+ readonly configKey?: string;
84
+ /** Set only for the passwordRef field — the stored goodvibes:// reference (never the raw value). */
85
+ readonly secretRef?: string;
86
+ }
87
+
88
+ /**
89
+ * Persist one `email.<field>` config value through the single reviewed path:
90
+ * non-secret fields go through cm.setDynamic after per-field validation; the
91
+ * password field (fieldName 'passwordRef', rawValue is the RAW password) is
92
+ * routed through persistSecretBackedConfigValue so only a goodvibes:// secret
93
+ * reference ever reaches settings.json. Never logs or returns the raw value.
94
+ *
95
+ * Reused by both the /email set CLI subcommand (handleSet, below) and the
96
+ * Agent workspace connect wizard editor — one persistence path, no fork.
97
+ */
98
+ export async function persistEmailConfigField(
99
+ cm: EmailConfigManagerLike,
100
+ secretsManager: EmailSecretsManagerLike,
101
+ fieldName: string,
102
+ rawValue: string,
103
+ ): Promise<PersistEmailConfigFieldResult> {
104
+ if (!KNOWN_EMAIL_FIELDS.has(fieldName)) {
105
+ return { ok: false, error: `Unknown email config key: ${fieldName}` };
106
+ }
107
+ const configKey = `email.${fieldName}`;
108
+
109
+ if (fieldName === 'passwordRef') {
110
+ const storedRef = await persistSecretBackedConfigValue(
111
+ cm as unknown as Parameters<typeof persistSecretBackedConfigValue>[0],
112
+ secretsManager as unknown as Parameters<typeof persistSecretBackedConfigValue>[1],
113
+ configKey as unknown as ConfigKey,
114
+ rawValue,
115
+ { scope: 'user' },
116
+ );
117
+ return { ok: true, configKey, secretRef: storedRef };
118
+ }
119
+
120
+ let coerced: unknown = rawValue;
121
+ if (fieldName === 'imapPort' || fieldName === 'smtpPort') {
122
+ const n = parseInt(rawValue, 10);
123
+ if (!isFinite(n) || n < 1 || n > 65535) {
124
+ return { ok: false, error: `Invalid port number: ${rawValue}. Must be an integer between 1 and 65535.`, configKey };
125
+ }
126
+ coerced = n;
127
+ } else if (fieldName === 'smtpSecurity') {
128
+ if (rawValue !== 'tls' && rawValue !== 'starttls' && rawValue !== 'auto') {
129
+ return {
130
+ ok: false,
131
+ error: `Invalid smtpSecurity value: "${rawValue}". Valid values: tls, starttls, auto.`,
132
+ configKey,
133
+ };
134
+ }
135
+ coerced = rawValue;
136
+ } else if (fieldName === 'enabled') {
137
+ const lower = rawValue.toLowerCase();
138
+ if (lower === 'true' || lower === '1' || lower === 'yes') {
139
+ coerced = true;
140
+ } else if (lower === 'false' || lower === '0' || lower === 'no') {
141
+ coerced = false;
142
+ } else {
143
+ return { ok: false, error: `Invalid boolean value: ${rawValue}. Use true or false.`, configKey };
144
+ }
145
+ }
146
+
147
+ cm.setDynamic(configKey, coerced);
148
+ return { ok: true, configKey };
149
+ }
150
+
64
151
  function buildEmailService(ctx: CommandContext): EmailService {
65
152
  const secretsManager = requireSecretsManager(ctx);
66
153
  const cm = getConfigManager(ctx);
@@ -247,58 +334,31 @@ async function handleSet(
247
334
  }
248
335
 
249
336
  const secretsManager = requireSecretsManager(ctx);
250
- const configKeyTyped = configKey as unknown as ConfigKey;
251
- const storedRef = await persistSecretBackedConfigValue(
252
- cm as unknown as Parameters<typeof persistSecretBackedConfigValue>[0],
253
- secretsManager,
254
- configKeyTyped,
255
- rawValue,
256
- { scope: 'user' },
257
- );
337
+ const result = await persistEmailConfigField(cm, secretsManager, fieldName, rawValue);
338
+ if (!result.ok) {
339
+ ctx.print(result.error ?? `Could not store ${configKey}.`);
340
+ return;
341
+ }
258
342
  cm.save();
259
343
  ctx.print(
260
344
  `Email config updated\n` +
261
345
  ` key: ${configKey}\n` +
262
- ` value: [stored as secret; reference: ${storedRef.slice(0, 40)}...]\n` +
346
+ ` value: [stored as secret; reference: ${(result.secretRef ?? '').slice(0, 40)}...]\n` +
263
347
  ' policy: raw password stored in secrets manager; settings.json contains only the goodvibes:// ref',
264
348
  );
265
349
  return;
266
350
  }
267
351
 
268
- // Non-secret path: coerce value and persist
269
- let coerced: unknown = rawValue;
270
- if (fieldName === 'imapPort' || fieldName === 'smtpPort') {
271
- const n = parseInt(rawValue, 10);
272
- if (!isFinite(n) || n < 1 || n > 65535) {
273
- ctx.print(`Invalid port number: ${rawValue}. Must be an integer between 1 and 65535.`);
274
- return;
275
- }
276
- coerced = n;
277
- } else if (fieldName === 'smtpSecurity') {
278
- // MIN-2: validate at set time so invalid values never reach the socket factory
279
- if (rawValue !== 'tls' && rawValue !== 'starttls' && rawValue !== 'auto') {
280
- ctx.print(
281
- `Invalid smtpSecurity value: "${rawValue}".\n` +
282
- 'Valid values: tls (direct TLS, e.g. port 465), starttls (STARTTLS upgrade, e.g. port 587), auto (port-based default).',
283
- );
284
- return;
285
- }
286
- coerced = rawValue;
287
- } else if (fieldName === 'enabled') {
288
- const lower = rawValue.toLowerCase();
289
- if (lower === 'true' || lower === '1' || lower === 'yes') {
290
- coerced = true;
291
- } else if (lower === 'false' || lower === '0' || lower === 'no') {
292
- coerced = false;
293
- } else {
294
- ctx.print(`Invalid boolean value: ${rawValue}. Use true or false.`);
295
- return;
296
- }
352
+ // Non-secret path: coerce, validate, and persist through the shared field helper
353
+ const secretsManager = requireSecretsManager(ctx);
354
+ const result = await persistEmailConfigField(cm, secretsManager, fieldName, rawValue);
355
+ if (!result.ok) {
356
+ ctx.print(result.error ?? `Could not update ${configKey}.`);
357
+ return;
297
358
  }
298
359
 
299
- cm.setDynamic(configKey as unknown as ConfigKey, coerced);
300
360
  cm.save();
301
- ctx.print(`Email config updated\n key: ${configKey}\n value: ${String(coerced)}`);
361
+ ctx.print(`Email config updated\n key: ${configKey}\n value: ${String(cm.get(configKey))}`);
302
362
  }
303
363
 
304
364
  async function handleSend(
@@ -128,7 +128,7 @@ function requireAgentKnowledgeAsk(context: CommandContext): ((input: KnowledgeAs
128
128
 
129
129
  export const knowledgeCommand: SlashCommand = {
130
130
  name: 'knowledge',
131
- aliases: ['know', 'kb'],
131
+ aliases: ['know'],
132
132
  description: 'Agent Knowledge: isolated Agent-owned status, ask/search, source/node/issue lists, item lookup, map, connectors, ingest, and review queue.',
133
133
  hidden: true,
134
134
  usage: '<subcommand> [args]',
@@ -0,0 +1,75 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+ import { requireShellPaths } from './runtime-services.ts';
3
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../../config/surface.ts';
4
+ import {
5
+ discoveredProvidersFilePath,
6
+ loadLanScanConsent,
7
+ saveLanScanConsent,
8
+ NETWORK_SCAN_DISABLE_COMMAND,
9
+ NETWORK_SCAN_ENABLE_COMMAND,
10
+ } from '../../runtime/lan-scan-consent.ts';
11
+
12
+ function statusText(storePath: string, decision: ReturnType<typeof loadLanScanConsent>): string {
13
+ if (!decision || decision.decision === 'declined') {
14
+ return [
15
+ 'Local network scanning: off',
16
+ ' What it would do Check other devices on your local network (your subnet only — nothing beyond it) for model servers such as Ollama or LM Studio.',
17
+ ` What it stores Found servers would be saved to ${storePath}.`,
18
+ ` Turn it on ${NETWORK_SCAN_ENABLE_COMMAND}`,
19
+ ].join('\n');
20
+ }
21
+ return [
22
+ 'Local network scanning: on',
23
+ ` Turned on ${decision.decidedAt}`,
24
+ ' What it does Checks other devices on your local network (your subnet only — nothing beyond it) for model servers such as Ollama or LM Studio, each time the agent starts.',
25
+ ` What it stores Found servers are saved to ${storePath}.`,
26
+ ' See discovered /provider',
27
+ ` Turn it off ${NETWORK_SCAN_DISABLE_COMMAND}`,
28
+ ].join('\n');
29
+ }
30
+
31
+ /**
32
+ * /network-scan — the consent + status surface for the Phase-8 LAN provider
33
+ * discovery scan (see src/runtime/lan-scan-consent.ts). Declining or never
34
+ * deciding is a first-class, fully supported path: the scan simply never
35
+ * runs until this command turns it on.
36
+ */
37
+ export function registerNetworkScanRuntimeCommands(registry: CommandRegistry): void {
38
+ registry.register({
39
+ name: 'network-scan',
40
+ aliases: ['discover-lan'],
41
+ description: 'Turn local-network scanning for model servers on or off, or check its status',
42
+ usage: '[on|off|status]',
43
+ argsHint: '[on|off|status]',
44
+ handler(args, ctx) {
45
+ const shellPaths = requireShellPaths(ctx);
46
+ const storePath = discoveredProvidersFilePath(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT);
47
+ const sub = (args[0] ?? 'status').toLowerCase();
48
+
49
+ if (sub === 'status' || sub === '') {
50
+ ctx.print(statusText(storePath, loadLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT)));
51
+ return;
52
+ }
53
+
54
+ if (sub === 'on' || sub === 'enable') {
55
+ saveLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT, 'granted');
56
+ ctx.print(
57
+ 'Local network scanning is now on. Starting next launch, the agent will check other devices on your ' +
58
+ `local network (your subnet only) for model servers such as Ollama or LM Studio, and save what it finds to ${storePath}. ` +
59
+ 'Run /provider to see discovered servers. Turn it off anytime with /network-scan off.',
60
+ );
61
+ return;
62
+ }
63
+
64
+ if (sub === 'off' || sub === 'disable') {
65
+ saveLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT, 'declined');
66
+ ctx.print(
67
+ `Local network scanning is now off. Nothing on your network will be probed. Turn it back on anytime with ${NETWORK_SCAN_ENABLE_COMMAND}.`,
68
+ );
69
+ return;
70
+ }
71
+
72
+ ctx.print(`Usage: /network-scan [on|off|status]\n${statusText(storePath, loadLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT))}`);
73
+ },
74
+ });
75
+ }