@pellux/goodvibes-agent 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +52 -7
  3. package/dist/package/main.js +7491 -12253
  4. package/docs/README.md +2 -2
  5. package/docs/channels-remote-and-api.md +1 -1
  6. package/docs/getting-started.md +2 -2
  7. package/docs/release-and-publishing.md +1 -1
  8. package/docs/tools-and-commands.md +5 -5
  9. package/package.json +4 -2
  10. package/release/live-verification/live-verification.json +13 -13
  11. package/release/live-verification/live-verification.md +15 -15
  12. package/release/performance-snapshot.json +2 -2
  13. package/release/release-notes.md +5 -7
  14. package/release/release-readiness.json +6 -6
  15. package/src/agent/behavior-discovery-summary.ts +4 -18
  16. package/src/agent/calendar-registry.ts +322 -0
  17. package/src/agent/competitive-feature-inventory.ts +268 -130
  18. package/src/agent/document-registry.ts +5 -1
  19. package/src/agent/email/email-service.ts +350 -0
  20. package/src/agent/email/imap-client.ts +596 -0
  21. package/src/agent/email/smtp-client.ts +453 -0
  22. package/src/agent/ics-calendar.ts +662 -0
  23. package/src/agent/ics-timezone.ts +172 -0
  24. package/src/agent/markdown-frontmatter.ts +31 -0
  25. package/src/agent/memory-safety.ts +1 -1
  26. package/src/agent/note-registry.ts +3 -9
  27. package/src/agent/persona-discovery.ts +3 -15
  28. package/src/agent/persona-registry.ts +1 -10
  29. package/src/agent/research-source-registry.ts +5 -1
  30. package/src/agent/routine-discovery.ts +3 -15
  31. package/src/agent/runtime-profile.ts +3 -0
  32. package/src/agent/skill-discovery.ts +3 -15
  33. package/src/agent/skill-draft-proposer.ts +203 -0
  34. package/src/agent/skill-draft-runner.ts +201 -0
  35. package/src/agent/skill-registry.ts +36 -1
  36. package/src/agent/skill-standard.ts +99 -0
  37. package/src/agent/vibe-file.ts +7 -22
  38. package/src/cli/completion.ts +1 -1
  39. package/src/cli/config-overrides.ts +10 -1
  40. package/src/cli/redaction.ts +17 -5
  41. package/src/config/provider-model.ts +2 -1
  42. package/src/config/secret-config.ts +13 -6
  43. package/src/core/activity-feed.ts +97 -0
  44. package/src/core/away-digest.ts +161 -0
  45. package/src/core/conversation-rendering.ts +7 -3
  46. package/src/core/conversation.ts +22 -15
  47. package/src/core/hardware-profile.ts +362 -0
  48. package/src/core/last-seen-store.ts +78 -0
  49. package/src/core/plain-language.ts +52 -0
  50. package/src/core/setup-incomplete-hint.ts +90 -0
  51. package/src/core/system-message-router.ts +38 -87
  52. package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
  53. package/src/input/agent-workspace-basic-command-editors.ts +39 -141
  54. package/src/input/agent-workspace-categories.ts +40 -125
  55. package/src/input/agent-workspace-command-editor.ts +4 -0
  56. package/src/input/agent-workspace-host-category.ts +0 -5
  57. package/src/input/agent-workspace-local-editor-submission.ts +3 -1
  58. package/src/input/agent-workspace-navigation.ts +10 -3
  59. package/src/input/agent-workspace-onboarding-actions.ts +132 -0
  60. package/src/input/agent-workspace-onboarding-categories.ts +29 -26
  61. package/src/input/agent-workspace-onboarding-finish.ts +11 -4
  62. package/src/input/agent-workspace-onboarding-state.ts +111 -0
  63. package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
  64. package/src/input/agent-workspace-profile-editors.ts +155 -0
  65. package/src/input/agent-workspace-subscription-editor.ts +7 -0
  66. package/src/input/agent-workspace-types.ts +5 -1
  67. package/src/input/agent-workspace.ts +65 -39
  68. package/src/input/command-registry.ts +18 -5
  69. package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
  70. package/src/input/commands/agent-skills-runtime.ts +60 -3
  71. package/src/input/commands/calendar-runtime.ts +209 -0
  72. package/src/input/commands/channels-runtime.ts +1 -0
  73. package/src/input/commands/command-error.ts +9 -0
  74. package/src/input/commands/compat-runtime.ts +1 -0
  75. package/src/input/commands/config.ts +1 -0
  76. package/src/input/commands/conversation-runtime.ts +1 -1
  77. package/src/input/commands/delegation-runtime.ts +5 -6
  78. package/src/input/commands/email-runtime.ts +387 -0
  79. package/src/input/commands/experience-runtime.ts +17 -4
  80. package/src/input/commands/guidance-runtime.ts +1 -1
  81. package/src/input/commands/health-runtime.ts +6 -1
  82. package/src/input/commands/knowledge-format.ts +73 -0
  83. package/src/input/commands/knowledge.ts +9 -74
  84. package/src/input/commands/local-provider-runtime.ts +2 -1
  85. package/src/input/commands/local-runtime.ts +10 -4
  86. package/src/input/commands/mcp-runtime.ts +7 -0
  87. package/src/input/commands/notify-runtime.ts +17 -1
  88. package/src/input/commands/onboarding-runtime.ts +1 -0
  89. package/src/input/commands/operator-actions-runtime.ts +1 -0
  90. package/src/input/commands/operator-runtime.ts +3 -0
  91. package/src/input/commands/personas-runtime.ts +1 -0
  92. package/src/input/commands/platform-access-runtime.ts +40 -17
  93. package/src/input/commands/product-runtime.ts +6 -1
  94. package/src/input/commands/provider-accounts-runtime.ts +3 -2
  95. package/src/input/commands/qrcode-runtime.ts +1 -0
  96. package/src/input/commands/routines-runtime.ts +1 -0
  97. package/src/input/commands/runtime-services.ts +0 -13
  98. package/src/input/commands/security-runtime.ts +1 -0
  99. package/src/input/commands/session-content.ts +1 -0
  100. package/src/input/commands/shell-core.ts +5 -2
  101. package/src/input/commands/subscription-runtime.ts +33 -9
  102. package/src/input/commands/support-bundle-runtime.ts +8 -1
  103. package/src/input/commands/tasks-runtime.ts +1 -0
  104. package/src/input/commands/tts-runtime.ts +1 -0
  105. package/src/input/commands/vibe-runtime.ts +1 -0
  106. package/src/input/commands.ts +4 -0
  107. package/src/input/feed-context-factory.ts +3 -12
  108. package/src/input/handler-command-route.ts +7 -60
  109. package/src/input/handler-feed-routes.ts +0 -194
  110. package/src/input/handler-feed.ts +2 -54
  111. package/src/input/handler-interactions.ts +0 -2
  112. package/src/input/handler-modal-stack.ts +0 -9
  113. package/src/input/handler-picker-routes.ts +24 -1
  114. package/src/input/handler-shortcuts.ts +11 -40
  115. package/src/input/handler-ui-state.ts +13 -0
  116. package/src/input/handler.ts +8 -35
  117. package/src/input/keybindings.ts +40 -17
  118. package/src/input/model-picker-local-fit.ts +130 -0
  119. package/src/input/shell-passthrough.ts +58 -0
  120. package/src/input/submission-router.ts +0 -5
  121. package/src/main.ts +129 -90
  122. package/src/renderer/activity-sidebar.ts +186 -0
  123. package/src/renderer/agent-workspace-context-lines.ts +5 -48
  124. package/src/renderer/agent-workspace-style.ts +1 -1
  125. package/src/renderer/agent-workspace.ts +14 -2
  126. package/src/renderer/compositor.ts +37 -125
  127. package/src/renderer/conversation-overlays.ts +3 -3
  128. package/src/renderer/help-overlay.ts +7 -5
  129. package/src/renderer/model-workspace.ts +101 -86
  130. package/src/{panels → renderer}/polish.ts +3 -3
  131. package/src/renderer/shell-surface.ts +4 -5
  132. package/src/renderer/status-token.ts +31 -11
  133. package/src/renderer/tab-strip.ts +1 -1
  134. package/src/renderer/tool-call.ts +7 -8
  135. package/src/renderer/tool-labels.ts +92 -0
  136. package/src/renderer/ui-factory.ts +48 -96
  137. package/src/runtime/bootstrap-command-context.ts +0 -5
  138. package/src/runtime/bootstrap-command-parts.ts +6 -15
  139. package/src/runtime/bootstrap-core.ts +34 -13
  140. package/src/runtime/bootstrap-hook-bridge.ts +3 -1
  141. package/src/runtime/bootstrap-shell.ts +3 -50
  142. package/src/runtime/bootstrap.ts +3 -4
  143. package/src/runtime/context.ts +1 -1
  144. package/src/runtime/diagnostics/panels/index.ts +0 -1
  145. package/src/runtime/diagnostics/panels/policy.ts +1 -1
  146. package/src/runtime/execution-ledger.ts +16 -1
  147. package/src/runtime/index.ts +6 -1
  148. package/src/runtime/onboarding/index.ts +1 -0
  149. package/src/runtime/onboarding/onboarding-state.ts +200 -0
  150. package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
  151. package/src/runtime/services.ts +16 -4
  152. package/src/runtime/terminal-output-guard.ts +7 -0
  153. package/src/runtime/tool-permission-safety.ts +1 -1
  154. package/src/runtime/ui/model-picker/data-provider.ts +1 -1
  155. package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
  156. package/src/runtime/ui/model-picker/types.ts +2 -2
  157. package/src/runtime/ui/provider-health/data-provider.ts +3 -3
  158. package/src/runtime/ui/provider-health/types.ts +2 -2
  159. package/src/runtime/ui-services.ts +0 -2
  160. package/src/shell/agent-workspace-fullscreen.ts +0 -1
  161. package/src/shell/autonomy-surfacing.ts +272 -0
  162. package/src/shell/session-continuity-hints.ts +0 -6
  163. package/src/shell/startup-wiring.ts +221 -0
  164. package/src/shell/ui-openers.ts +18 -29
  165. package/src/tools/agent-context-policy.ts +1 -1
  166. package/src/tools/agent-harness-background-processes.ts +4 -4
  167. package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
  168. package/src/tools/agent-harness-command-runner.ts +6 -1
  169. package/src/tools/agent-harness-document-ops.ts +26 -53
  170. package/src/tools/agent-harness-execution-history.ts +1 -13
  171. package/src/tools/agent-harness-execution-posture.ts +0 -15
  172. package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
  173. package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
  174. package/src/tools/agent-harness-metadata.ts +16 -0
  175. package/src/tools/agent-harness-mode-catalog.ts +2 -9
  176. package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
  177. package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
  178. package/src/tools/agent-harness-tool-schema.ts +13 -14
  179. package/src/tools/agent-harness-tool.ts +27 -23
  180. package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
  181. package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
  182. package/src/tools/agent-workspace-tool.ts +2 -33
  183. package/src/utils/terminal-width.ts +9 -3
  184. package/src/version.ts +1 -1
  185. package/src/input/agent-workspace-panel-route.ts +0 -44
  186. package/src/input/panel-integration-actions.ts +0 -26
  187. package/src/panels/approval-panel.ts +0 -149
  188. package/src/panels/automation-control-panel.ts +0 -212
  189. package/src/panels/base-panel.ts +0 -254
  190. package/src/panels/builtin/agent.ts +0 -58
  191. package/src/panels/builtin/knowledge.ts +0 -26
  192. package/src/panels/builtin/operations.ts +0 -121
  193. package/src/panels/builtin/session.ts +0 -138
  194. package/src/panels/builtin/shared.ts +0 -275
  195. package/src/panels/builtin/usage.ts +0 -21
  196. package/src/panels/builtin-panels.ts +0 -23
  197. package/src/panels/confirm-state.ts +0 -61
  198. package/src/panels/context-visualizer-panel.ts +0 -204
  199. package/src/panels/cost-tracker-panel.ts +0 -444
  200. package/src/panels/docs-panel.ts +0 -285
  201. package/src/panels/index.ts +0 -25
  202. package/src/panels/knowledge-panel.ts +0 -417
  203. package/src/panels/memory-panel.ts +0 -226
  204. package/src/panels/panel-list-panel.ts +0 -464
  205. package/src/panels/panel-manager.ts +0 -570
  206. package/src/panels/provider-accounts-panel.ts +0 -233
  207. package/src/panels/provider-health-domains.ts +0 -208
  208. package/src/panels/provider-health-panel.ts +0 -720
  209. package/src/panels/provider-health-tracker.ts +0 -115
  210. package/src/panels/provider-stats-panel.ts +0 -366
  211. package/src/panels/qr-panel.ts +0 -207
  212. package/src/panels/schedule-panel.ts +0 -321
  213. package/src/panels/scrollable-list-panel.ts +0 -491
  214. package/src/panels/search-focus.ts +0 -32
  215. package/src/panels/security-panel.ts +0 -295
  216. package/src/panels/session-browser-panel.ts +0 -395
  217. package/src/panels/session-maintenance.ts +0 -125
  218. package/src/panels/subscription-panel.ts +0 -263
  219. package/src/panels/system-messages-panel.ts +0 -230
  220. package/src/panels/tasks-panel.ts +0 -344
  221. package/src/panels/thinking-panel.ts +0 -304
  222. package/src/panels/token-budget-panel.ts +0 -475
  223. package/src/panels/tool-inspector-panel.ts +0 -436
  224. package/src/panels/types.ts +0 -54
  225. package/src/renderer/panel-composite.ts +0 -158
  226. package/src/renderer/panel-tab-bar.ts +0 -69
  227. package/src/renderer/panel-workspace-bar.ts +0 -42
  228. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  229. package/src/tools/agent-harness-panel-metadata.ts +0 -211
  230. /package/src/runtime/perf/{panel-health-monitor.ts → component-health.ts} +0 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Ambient autonomy surfacing module.
3
+ *
4
+ * HISTORY: this factory was extracted from main.ts to keep that file under the
5
+ * 800-line cap. After extraction it gained two capabilities:
6
+ * 1. Calendar merging — listCalendarEvents callback merges upcoming events
7
+ * into the Coming-up sidebar alongside scheduled jobs (buildCalendarEventsLister).
8
+ * 2. Skill-draft accrual — onAwayDigest callback runs skill-draft proposal
9
+ * once per away-digest pass and appends a feed line when drafts are created
10
+ * (buildSkillDraftProposer).
11
+ * These additions are NOT "no behavior change" refactors; they add real surface.
12
+ */
13
+
14
+ import { buildAwayDigest, formatDigestTime, formatRelativeTime } from '../core/away-digest.ts';
15
+ import { LastSeenStore } from '../core/last-seen-store.ts';
16
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
17
+ import { AgentCalendarRegistry } from '../agent/calendar-registry.ts';
18
+ import { AgentSkillRegistry } from '../agent/skill-registry.ts';
19
+ import { runSkillDraftProposer } from '../agent/skill-draft-runner.ts';
20
+ import type { CommandContext } from '../input/command-registry.ts';
21
+
22
+ interface AutomationJobLike {
23
+ readonly name: string;
24
+ readonly enabled: boolean;
25
+ readonly nextRunAt?: number;
26
+ readonly lastRunAt?: number;
27
+ readonly runCount?: number;
28
+ }
29
+
30
+ interface ApprovalLike {
31
+ readonly status: string;
32
+ }
33
+
34
+ interface AutonomyMessageRouter {
35
+ high(message: string): void;
36
+ getFeed(): { push(text: string, priority?: 'high' | 'low', kind?: string): void } | null;
37
+ }
38
+
39
+ export interface AutonomySurfacingOptions {
40
+ readonly shellPaths: Parameters<typeof LastSeenStore.fromShellPaths>[0];
41
+ readonly listAutomationJobs: () => readonly AutomationJobLike[];
42
+ readonly listApprovals: () => readonly ApprovalLike[];
43
+ readonly getTasksSnapshot: () => readonly unknown[];
44
+ readonly router: AutonomyMessageRouter;
45
+ readonly render: () => void;
46
+ /**
47
+ * Optional callback to list upcoming local calendar events.
48
+ * Each item carries a formatted label and a numeric sort key (ms since epoch)
49
+ * so the Coming up section merges calendar and job entries chronologically.
50
+ */
51
+ readonly listCalendarEvents?: () => readonly { label: string; start: number }[];
52
+ /**
53
+ * Optional callback invoked once per announceAwayDigest pass.
54
+ * Should run skill draft proposal and return the count of drafts created.
55
+ * When > 0, a line is appended to the digest feed.
56
+ */
57
+ readonly onAwayDigest?: () => number;
58
+ }
59
+
60
+ const COMING_UP_TTL_MS = 60_000;
61
+ const FETCH_TIMEOUT_MS = 2500;
62
+ const LAST_SEEN_REFRESH_MS = 5 * 60_000;
63
+
64
+ /**
65
+ * Ambient autonomy surfacing for the shell: the launch "While you were away"
66
+ * digest and the sidebar's Coming up entries. Everything here is best-effort
67
+ * and offline-tolerant — failures are silent and renders are never blocked.
68
+ */
69
+ export function createAutonomySurfacing(options: AutonomySurfacingOptions) {
70
+ const lastSeenStore = LastSeenStore.fromShellPaths(options.shellPaths);
71
+
72
+ // Refresh lastSeenAt every 5 minutes so rapid restarts still detect activity.
73
+ const lastSeenRefreshInterval = setInterval(() => {
74
+ lastSeenStore.save();
75
+ }, LAST_SEEN_REFRESH_MS);
76
+ lastSeenRefreshInterval.unref();
77
+
78
+ const comingUpCache: { items: string[]; fetchedAt: number | null; fetching: boolean } = {
79
+ items: [],
80
+ fetchedAt: null,
81
+ fetching: false,
82
+ };
83
+
84
+ function refreshComingUp(): void {
85
+ if (comingUpCache.fetching) return;
86
+ const now = Date.now();
87
+ if (comingUpCache.fetchedAt !== null && now - comingUpCache.fetchedAt < COMING_UP_TTL_MS) return;
88
+ comingUpCache.fetching = true;
89
+ Promise.resolve().then(() => {
90
+ try {
91
+ const jobs = options.listAutomationJobs();
92
+ const jobItems: Array<{ label: string; sortKey: number }> = jobs
93
+ .filter((job) => job.enabled && job.nextRunAt !== undefined && job.nextRunAt > now)
94
+ .sort((a, b) => (a.nextRunAt ?? 0) - (b.nextRunAt ?? 0))
95
+ .map((job) => {
96
+ const when = job.nextRunAt ? formatRelativeTime(job.nextRunAt, now) : '';
97
+ const name = job.name.length > 22 ? `${job.name.slice(0, 20)}…` : job.name;
98
+ return { label: when ? `${name} — ${when}` : name, sortKey: job.nextRunAt ?? 0 };
99
+ });
100
+
101
+ const calItems: Array<{ label: string; sortKey: number }> = [];
102
+ if (options.listCalendarEvents) {
103
+ try {
104
+ for (const ev of options.listCalendarEvents()) {
105
+ calItems.push({ label: ev.label, sortKey: ev.start });
106
+ }
107
+ } catch {
108
+ // Calendar unavailable — skip silently.
109
+ }
110
+ }
111
+
112
+ comingUpCache.items = [...jobItems, ...calItems]
113
+ .sort((a, b) => a.sortKey - b.sortKey)
114
+ .slice(0, 3)
115
+ .map((item) => item.label);
116
+ comingUpCache.fetchedAt = Date.now();
117
+ } catch {
118
+ // Offline or manager unavailable — leave cache as-is.
119
+ } finally {
120
+ comingUpCache.fetching = false;
121
+ }
122
+ }).catch(() => {
123
+ comingUpCache.fetching = false;
124
+ });
125
+ }
126
+
127
+ function announceAwayDigest(): void {
128
+ void (async () => {
129
+ try {
130
+ const lastSeenAt = lastSeenStore.read();
131
+ const withTimeout = <T>(get: () => T) => Promise.race([
132
+ Promise.resolve(get()),
133
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), FETCH_TIMEOUT_MS)),
134
+ ]);
135
+
136
+ const [jobsResult, tasksResult] = await Promise.allSettled([
137
+ withTimeout(options.listAutomationJobs),
138
+ withTimeout(options.getTasksSnapshot),
139
+ ]);
140
+ const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [];
141
+ const allTasks = tasksResult.status === 'fulfilled' ? tasksResult.value : [];
142
+
143
+ const firedSchedules = lastSeenAt !== null
144
+ ? jobs
145
+ .filter((job) => job.lastRunAt !== undefined && job.lastRunAt > lastSeenAt)
146
+ .map((job) => ({ name: job.name, lastRunAt: job.lastRunAt, runCount: job.runCount ?? 0 }))
147
+ : [];
148
+
149
+ const changedTasks = lastSeenAt !== null
150
+ ? allTasks
151
+ .filter((task) => {
152
+ const completedAt = (task as { completedAt?: number }).completedAt;
153
+ return completedAt !== undefined ? completedAt > lastSeenAt : false;
154
+ })
155
+ .map((task) => ({
156
+ title: (task as { title?: string; name?: string }).title
157
+ ?? (task as { name?: string }).name
158
+ ?? 'Task',
159
+ status: (task as { status?: string }).status ?? 'done',
160
+ completedAt: (task as { completedAt?: number }).completedAt,
161
+ }))
162
+ : [];
163
+
164
+ const pendingApprovals = options.listApprovals()
165
+ .filter((approval) => approval.status === 'pending').length;
166
+
167
+ const digest = buildAwayDigest({
168
+ lastSeenAt,
169
+ schedules: firedSchedules,
170
+ tasks: changedTasks,
171
+ pendingApprovals,
172
+ });
173
+
174
+ if (digest !== null) {
175
+ const full = [digest.headline, ...digest.lines].join('\n ');
176
+ options.router.high(`[Status] ${full}`);
177
+ for (const line of digest.lines) {
178
+ options.router.getFeed()?.push(`[Status] ${line}`, 'low', 'schedule');
179
+ }
180
+ options.render();
181
+ }
182
+
183
+ // Invoke optional skill-draft hook once per pass.
184
+ if (options.onAwayDigest) {
185
+ try {
186
+ const drafted = options.onAwayDigest();
187
+ if (drafted > 0) {
188
+ options.router.getFeed()?.push(
189
+ `[Status] I drafted ${drafted} skill${drafted !== 1 ? 's' : ''} from recent work — review them under Memory`,
190
+ 'low',
191
+ 'schedule',
192
+ );
193
+ }
194
+ } catch {
195
+ // Skill draft hook failed — skip silently.
196
+ }
197
+ }
198
+ } catch (err) {
199
+ logger.debug('away-digest failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
200
+ }
201
+ })();
202
+ }
203
+
204
+ function stop(): void {
205
+ clearInterval(lastSeenRefreshInterval);
206
+ lastSeenStore.save();
207
+ }
208
+
209
+ return {
210
+ refreshComingUp,
211
+ announceAwayDigest,
212
+ stop,
213
+ // Return a defensive copy so callers cannot mutate internal cache state.
214
+ comingUpItems: (): readonly string[] => [...comingUpCache.items],
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Build the onAwayDigest callback for the given shellPaths and command context.
220
+ * Extracted so main.ts can pass a single symbol instead of an inline lambda.
221
+ * (See module-header note for history.)
222
+ */
223
+ export function buildSkillDraftProposer(
224
+ shellPaths: Parameters<typeof AgentSkillRegistry.fromShellPaths>[0],
225
+ context: CommandContext,
226
+ ): () => number {
227
+ return () =>
228
+ runSkillDraftProposer(
229
+ context,
230
+ AgentSkillRegistry.fromShellPaths(shellPaths),
231
+ ).proposed;
232
+ }
233
+
234
+ /**
235
+ * Build the listCalendarEvents callback for the given shellPaths.
236
+ * Extracts the closure that was previously inlined in main.ts so that
237
+ * file stays under the 800-line cap.
238
+ *
239
+ * The optional `upcomingEvents` parameter is a testability seam: when provided,
240
+ * it replaces the `registry.upcoming(7)` call so unit tests can inject a
241
+ * deterministic event set without mocking the module. Production callers omit
242
+ * this parameter; the default is the real registry lookup.
243
+ */
244
+ export function buildCalendarEventsLister(
245
+ shellPaths: Parameters<typeof LastSeenStore.fromShellPaths>[0],
246
+ upcomingEvents?: () => readonly import('../agent/calendar-registry.ts').AgentCalendarEvent[],
247
+ ): () => readonly { label: string; start: number }[] {
248
+ return () => {
249
+ const now = Date.now();
250
+ const registry = AgentCalendarRegistry.fromShellPaths(shellPaths);
251
+ return (upcomingEvents ?? (() => registry.upcoming(7)))()
252
+ .map((e) => {
253
+ const allDay = e.allDay;
254
+ // Sort key: use local midnight for all-day events so they sort at the
255
+ // correct local calendar date rather than UTC midnight (which would land
256
+ // the previous day for timezones east of UTC).
257
+ // Timed events: parse the ISO string directly; Date.parse handles
258
+ // offset-bearing strings correctly.
259
+ const startMs = allDay
260
+ ? new Date(`${e.start}T00:00:00`).getTime() // no tz suffix ⇒ local time
261
+ : Date.parse(e.start);
262
+ const sortMs = isNaN(startMs) ? now : startMs;
263
+ // Display: format via local Date methods so the user sees their own
264
+ // timezone, never a raw stored offset or UTC-shifted string.
265
+ const label = e.title.length > 22 ? `${e.title.slice(0, 20)}…` : e.title;
266
+ const when = allDay
267
+ ? e.start // date-only string, no time component to misformat
268
+ : formatDigestTime(startMs, now);
269
+ return { label: `${label} — ${when}`, start: sortMs };
270
+ });
271
+ };
272
+ }
@@ -18,15 +18,10 @@ interface ShellRemoteSnapshot {
18
18
  readonly contracts: readonly ShellRemoteContract[];
19
19
  }
20
20
 
21
- interface ShellPanelRecord {
22
- readonly id: string;
23
- }
24
-
25
21
  export function buildShellSessionContinuityHints(
26
22
  sessionSnapshot: ShellSessionSnapshot,
27
23
  tasksSnapshot: ShellTaskSnapshot,
28
24
  remoteSnapshot: ShellRemoteSnapshot,
29
- openPanels: readonly ShellPanelRecord[],
30
25
  ) {
31
26
  return {
32
27
  pendingApprovals: sessionSnapshot.pendingApproval ? 1 : 0,
@@ -34,6 +29,5 @@ export function buildShellSessionContinuityHints(
34
29
  blockedTasks: tasksSnapshot.tasks.filter((task) => task.status === 'blocked').length,
35
30
  remoteContracts: remoteSnapshot.contracts.length,
36
31
  remoteRunners: remoteSnapshot.contracts.slice(0, 4).map((contract) => contract.runnerId),
37
- openPanels: openPanels.map((panel) => panel.id),
38
32
  };
39
33
  }
@@ -0,0 +1,221 @@
1
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
2
+ import type { HookDispatcher, HookEventPath, HookPhase, HookCategory } from '@pellux/goodvibes-sdk/platform/hooks';
3
+ import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
4
+ import {
5
+ checkRecoveryFile,
6
+ formatReturnContextForDisplay,
7
+ persistConversation,
8
+ writeRecoveryFile,
9
+ } from '@/runtime/index.ts';
10
+ import type { SessionSnapshot } from '@/runtime/index.ts';
11
+ import {
12
+ readOnboardingCheckMarker,
13
+ readOnboardingCompletionMarker,
14
+ } from '../runtime/onboarding/index.ts';
15
+ import { deriveOnboardingState } from '../runtime/onboarding/onboarding-state.ts';
16
+ import { buildSetupIncompleteHint } from '../core/setup-incomplete-hint.ts';
17
+
18
+ export interface SessionPersistenceAndRecoveryDeps {
19
+ readonly buildCurrentSessionSnapshot: () => SessionSnapshot;
20
+ readonly runtime: { readonly sessionId: string; readonly model: string; readonly provider: string };
21
+ readonly conversation: { readonly title?: string | null };
22
+ readonly workingDir: string;
23
+ readonly homeDirectory: string;
24
+ readonly systemMessageRouter: { high(message: string): void; low(message: string): void };
25
+ readonly render: () => void;
26
+ readonly unsubs: Array<() => void>;
27
+ readonly uiServicesTurns: {
28
+ on(event: 'TURN_COMPLETED' | 'STREAM_START' | 'STREAM_DELTA', handler: () => void): () => void;
29
+ };
30
+ readonly hookDispatcher: HookDispatcher;
31
+ readonly sessionManager: SessionManager;
32
+ /**
33
+ * Called whenever the computed stream-token speed changes so that main.ts
34
+ * (which owns the render closure) can keep the value up to date.
35
+ */
36
+ readonly onStreamSpeedUpdate: (tokensPerSecond: number) => void;
37
+ }
38
+
39
+ export interface SessionPersistenceAndRecoveryResult {
40
+ /** Interval handle for the periodic recovery-file writer; clear it on exit. */
41
+ recoveryInterval: ReturnType<typeof setInterval>;
42
+ /** True if an unsaved recovery session was found and the user prompt was shown. */
43
+ recoveryPending: boolean;
44
+ }
45
+
46
+ /**
47
+ * Wires streaming-speed event subscriptions, auto-save on turn completion,
48
+ * and the post-first-render recovery-file check + periodic writer.
49
+ *
50
+ * Call AFTER the first render() so that:
51
+ * - announceAwayDigest has already run
52
+ * - startHardwareProbe has already fired
53
+ * - The recovery prompt lands as ambient context, not a startup blocker
54
+ */
55
+ export function wireSessionPersistenceAndRecovery(
56
+ deps: SessionPersistenceAndRecoveryDeps,
57
+ ): SessionPersistenceAndRecoveryResult {
58
+ const {
59
+ buildCurrentSessionSnapshot,
60
+ runtime,
61
+ conversation,
62
+ workingDir,
63
+ homeDirectory,
64
+ systemMessageRouter,
65
+ render,
66
+ unsubs,
67
+ uiServicesTurns,
68
+ hookDispatcher,
69
+ sessionManager,
70
+ onStreamSpeedUpdate,
71
+ } = deps;
72
+
73
+ // --- Streaming speed + tool preview wiring ---
74
+ let streamStartTime = 0;
75
+ let streamDeltaCount = 0;
76
+
77
+ unsubs.push(uiServicesTurns.on('TURN_COMPLETED', () => {
78
+ // Auto-save after every LLM turn so kills don't lose the session
79
+ try {
80
+ const snapshot = buildCurrentSessionSnapshot();
81
+ persistConversation(
82
+ runtime.sessionId,
83
+ snapshot,
84
+ runtime.model,
85
+ runtime.provider,
86
+ conversation.title || '',
87
+ { workingDirectory: workingDir, homeDirectory, sessionManager },
88
+ );
89
+ hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
90
+ } catch (e) { logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) }); }
91
+ }));
92
+
93
+ unsubs.push(uiServicesTurns.on('STREAM_START', () => {
94
+ streamStartTime = Date.now();
95
+ streamDeltaCount = 0;
96
+ onStreamSpeedUpdate(0);
97
+ }));
98
+ unsubs.push(uiServicesTurns.on('STREAM_DELTA', () => {
99
+ streamDeltaCount++;
100
+ const elapsed = (Date.now() - streamStartTime) / 1000;
101
+ // Note: counts stream deltas, not actual tokens. ~1 delta per token for most providers.
102
+ onStreamSpeedUpdate(elapsed > 0 ? streamDeltaCount / elapsed : 0);
103
+ }));
104
+
105
+ // Recovery file check: display prompt if an unsaved session exists.
106
+ // Runs after the first render so the message lands as ambient context.
107
+ let recoveryPending = false;
108
+ const recoveryInfo = checkRecoveryFile({ workingDirectory: workingDir, homeDirectory });
109
+ if (recoveryInfo) {
110
+ systemMessageRouter.high(`[Recovery] Found unsaved session from ${new Date(recoveryInfo.timestamp).toLocaleString()}. Title: "${recoveryInfo.title}". Press Ctrl+R to restore, Esc to discard, or start typing to ignore it.`);
111
+ for (const line of formatReturnContextForDisplay(recoveryInfo.returnContext)) {
112
+ systemMessageRouter.low(`[Recovery] ${line}`);
113
+ }
114
+ render();
115
+ recoveryPending = true;
116
+ }
117
+
118
+ const recoveryInterval = setInterval(() => {
119
+ const snapshot = buildCurrentSessionSnapshot();
120
+ writeRecoveryFile(
121
+ snapshot,
122
+ runtime.sessionId,
123
+ conversation.title ?? '',
124
+ { workingDirectory: workingDir, homeDirectory },
125
+ );
126
+ }, 60_000);
127
+
128
+ return { recoveryInterval, recoveryPending };
129
+ }
130
+
131
+ /**
132
+ * Deps for the setup-incomplete hint, kept narrow so the function is easily
133
+ * testable and doesn't pull in the full bootstrap context.
134
+ */
135
+ export interface SetupIncompleteHintDeps {
136
+ /**
137
+ * Shell path service — used to locate onboarding marker files.
138
+ * Must satisfy the minimal interface required by readOnboardingCheckMarker.
139
+ */
140
+ readonly shellPaths: Parameters<typeof readOnboardingCheckMarker>[0];
141
+ /**
142
+ * Whether a model route is currently configured and usable for chat.
143
+ * Pass true when the provider registry has a ready model, false otherwise.
144
+ */
145
+ readonly providerReady: boolean;
146
+ /**
147
+ * Whether a local model route has been detected and is ready for chat.
148
+ * Pass true when the local-model-cookbook status === 'detected-local-route'.
149
+ * Omit or pass false when unavailable. Best-effort: errors should produce false.
150
+ * This mirrors the 'local-model-readiness' plan item in the real buildSetupPlan.
151
+ */
152
+ readonly localReady?: boolean;
153
+ /**
154
+ * Whether the assistant service (connected host) is known to be running.
155
+ * Pass true/false for a confirmed live signal; omit or pass null/undefined
156
+ * when the signal is unreliable or unavailable at startup time.
157
+ */
158
+ readonly hostReady?: boolean | null;
159
+ /** Low-priority message channel — same interface as SystemMessageRouter.low(). */
160
+ readonly systemMessageRouter: { low(message: string): void };
161
+ }
162
+
163
+ /**
164
+ * Derives the current onboarding state from disk markers and, if setup is
165
+ * in-progress, pushes a plain-language hint to the conversation feed.
166
+ *
167
+ * Best-effort: any error is caught silently so startup is never blocked.
168
+ * Call after the first render, alongside announceAwayDigest.
169
+ */
170
+ export function wireSetupIncompleteHint(deps: SetupIncompleteHintDeps): void {
171
+ try {
172
+ const { shellPaths, providerReady, localReady, hostReady, systemMessageRouter } = deps;
173
+
174
+ const checkMarker = readOnboardingCheckMarker(shellPaths, 'user');
175
+ const completionMarker = readOnboardingCompletionMarker(shellPaths, 'user');
176
+
177
+ // Build a minimal plan with BOTH provider-access AND local-model-readiness so
178
+ // that deriveReadyToChat mirrors the workspace's canonical definition:
179
+ // readyToChat = providerItem.ready OR localModelItem.ready
180
+ //
181
+ // local-model-readiness uses blocksAutonomy:false so it never becomes a false
182
+ // blocker when only the local route is available (matching real plan behaviour).
183
+ // We intentionally skip the full buildSetupPlan (requires CommandContext +
184
+ // async collectSnapshot) because this is a best-effort ambient hint.
185
+ const minimalPlan = [
186
+ {
187
+ id: 'provider-access',
188
+ label: 'Model access',
189
+ status: providerReady ? ('ready' as const) : ('blocked' as const),
190
+ priority: 20,
191
+ blocksAutonomy: true,
192
+ reason: '',
193
+ nextAction: 'Choose a model to start chatting.',
194
+ userRoute: 'Agent Workspace -> Start -> Choose main model',
195
+ modelRoute: '',
196
+ },
197
+ {
198
+ id: 'local-model-readiness',
199
+ label: 'Local model',
200
+ status: localReady ? ('ready' as const) : ('recommended' as const),
201
+ priority: 25,
202
+ blocksAutonomy: false,
203
+ reason: '',
204
+ nextAction: 'Run /agent to set up a local model route.',
205
+ userRoute: 'Agent Workspace -> Models -> Local models',
206
+ modelRoute: '',
207
+ },
208
+ ];
209
+
210
+ const state = deriveOnboardingState({ plan: minimalPlan, checkMarker, completionMarker });
211
+
212
+ const hint = buildSetupIncompleteHint(state, hostReady);
213
+ if (hint === null) return;
214
+
215
+ for (const line of hint.lines) {
216
+ systemMessageRouter.low(`[Setup] ${line}`);
217
+ }
218
+ } catch {
219
+ // Never block startup on hint errors.
220
+ }
221
+ }
@@ -3,7 +3,6 @@ import { getProviderIdFromModel } from '../config/provider-model.ts';
3
3
  import type { ConversationManager } from '../core/conversation';
4
4
  import type { CommandContext } from '../input/command-registry.ts';
5
5
  import type { InputHandler } from '../input/handler.ts';
6
- import type { PanelManager } from '../panels/panel-manager.ts';
7
6
  import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
8
7
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
9
8
  import type { MutableRuntimeState } from '@/runtime/index.ts';
@@ -13,13 +12,12 @@ import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config'
13
12
  import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
14
13
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
15
14
  import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
15
+ import { buildLocalFitRecommendations, buildSignInRow, LOCAL_REC_PROVIDER } from '../input/model-picker-local-fit.ts';
16
16
  import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
17
- import { agentWorkspaceCategoryForPanel } from '../input/agent-workspace-panel-route.ts';
18
17
 
19
18
  type WireShellUiOpenersOptions = {
20
19
  commandContext: CommandContext;
21
20
  input: InputHandler;
22
- panelManager: PanelManager;
23
21
  conversation: ConversationManager;
24
22
  configManager: ConfigManager;
25
23
  providerRegistry: ProviderRegistry;
@@ -84,7 +82,6 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
84
82
  const {
85
83
  commandContext,
86
84
  input,
87
- panelManager,
88
85
  conversation,
89
86
  configManager,
90
87
  providerRegistry,
@@ -190,10 +187,19 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
190
187
 
191
188
  commandContext.openModelPicker = () => {
192
189
  void (async () => {
193
- const models = providerRegistry.getSelectableModels();
190
+ const catalogModels = providerRegistry.getSelectableModels();
194
191
  const configuredIds = new Set(getConfiguredProviderIds());
195
192
  input.modelPicker.configuredProviders = configuredIds;
196
- const providerIds = [...new Set(models.map((m) => m.provider))];
193
+
194
+ // CRITICAL GATE: inject synthetic local recommendations ONLY when zero
195
+ // providers are configured. When any real credential exists these must
196
+ // not be present in the list. The sign-in row is appended last so it
197
+ // is always reachable but does not displace hardware-fit entries.
198
+ const models = configuredIds.size === 0
199
+ ? [...buildLocalFitRecommendations(), buildSignInRow(), ...catalogModels]
200
+ : catalogModels;
201
+
202
+ const providerIds = [...new Set(models.map((m) => m.provider).filter((p) => p !== LOCAL_REC_PROVIDER))];
197
203
  const secretProviderIds = await resolveSecretProviderIds();
198
204
  input.modelPicker.configuredViaMap = buildConfiguredViaMap(providerIds, configuredIds, subscriptionManager, secretProviderIds);
199
205
  void getPinned().then((pinned) => {
@@ -202,7 +208,11 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
202
208
  void input.modelPicker.loadRecentModels().catch(() => {}); // best-effort: prefetch for UI, failure is non-visible
203
209
  input.modalOpened('modelPicker');
204
210
  input.modelPicker.setTargetInfos(buildModelPickerTargets());
205
- input.modelPicker.openAllModels(models, getCurrentModelForPickerTarget());
211
+ // Pre-select the best-fit local recommendation when no providers are configured.
212
+ const preSelectId = configuredIds.size === 0 && models.length > 0
213
+ ? (models[0]?.registryKey ?? models[0]?.id ?? getCurrentModelForPickerTarget())
214
+ : getCurrentModelForPickerTarget();
215
+ input.modelPicker.openAllModels(models, preSelectId);
206
216
  render();
207
217
  })().catch((error: unknown) => {
208
218
  commandContext.print?.(`Model picker failed to open: ${error instanceof Error ? error.message : String(error)}`);
@@ -439,36 +449,15 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
439
449
  render();
440
450
  };
441
451
 
442
- commandContext.openPanelPicker = () => {
443
- panelManager.hide();
444
- input.panelFocused = false;
452
+ commandContext.openWorkspacePicker = () => {
445
453
  conversation.setSplashSuppressed(false);
446
- conversation.log('Panel picker is handled through Agent Workspace. Use /agent for current operator controls.', { fg: '214' });
447
454
  input.openAgentWorkspace(commandContext, 'home');
448
455
  conversation.rebuildHistory();
449
456
  render();
450
457
  };
451
458
 
452
- commandContext.focusPanels = () => {
453
- input.panelFocused = false;
454
- input.openAgentWorkspace(commandContext, 'home');
455
- render();
456
- };
457
-
458
459
  commandContext.focusPrompt = () => {
459
- input.panelFocused = false;
460
460
  input.indicatorFocused = false;
461
461
  render();
462
462
  };
463
-
464
- commandContext.showPanel = (panelId, pane) => {
465
- void pane;
466
- panelManager.hide();
467
- input.panelFocused = false;
468
- conversation.setSplashSuppressed(false);
469
- conversation.log(`Panel route "${panelId}" is handled through Agent Workspace. Opening the matching operator area.`, { fg: '214' });
470
- input.openAgentWorkspace(commandContext, agentWorkspaceCategoryForPanel(panelId));
471
- conversation.rebuildHistory();
472
- render();
473
- };
474
463
  }
@@ -160,7 +160,7 @@ function buildAgentCapabilitiesContract(registry?: ToolRegistry): Record<string,
160
160
  },
161
161
  {
162
162
  area: 'Harness operation',
163
- can: 'Discover and use harness modes, slash commands, workspace actions, settings, panels, UI surfaces, keybindings, and model tools.',
163
+ can: 'Discover and use harness modes, slash commands, workspace actions, settings, UI surfaces, keybindings, and model tools.',
164
164
  tools: [optionalTool('agent_harness'), optionalTool('host'), optionalTool('settings'), 'goodvibes_context'],
165
165
  inspect: 'agent_harness mode:"modes" query:"capability"',
166
166
  },
@@ -658,7 +658,7 @@ export async function runBackgroundProcessAction(context: CommandContext, args:
658
658
  const action = readProcessAction(args);
659
659
  if (action === 'write') {
660
660
  const confirmationError = requireConfirmed(args, 'Background process stdin write');
661
- if (confirmationError) throw new Error(confirmationError);
661
+ if (confirmationError) return { status: 'needs_confirmation', reason: confirmationError, policy: 'Writing stdin to a background process requires confirm:true and explicitUserRequest.' };
662
662
  const writeInput = firstManagerFunction(manager, STDIN_WRITE_METHOD_NAMES);
663
663
  if (!writeInput) {
664
664
  return {
@@ -710,7 +710,7 @@ export async function runBackgroundProcessAction(context: CommandContext, args:
710
710
  }
711
711
  if (action === 'start' || action === 'spawn' || action === 'run') {
712
712
  const confirmationError = requireConfirmed(args, 'Background process start');
713
- if (confirmationError) throw new Error(confirmationError);
713
+ if (confirmationError) return { status: 'needs_confirmation', reason: confirmationError, policy: 'Starting a background process requires confirm:true and explicitUserRequest.' };
714
714
  const command = readCommand(args);
715
715
  if (!command) throw new Error('Background process start requires command.');
716
716
  if (looksLikeSudo(command)) {
@@ -749,7 +749,7 @@ export async function runBackgroundProcessAction(context: CommandContext, args:
749
749
 
750
750
  if (action === 'stop' || action === 'kill' || action === 'cancel') {
751
751
  const confirmationError = requireConfirmed(args, 'Background process stop');
752
- if (confirmationError) throw new Error(confirmationError);
752
+ if (confirmationError) return { status: 'needs_confirmation', reason: confirmationError, policy: 'Stopping a background process requires confirm:true and explicitUserRequest.' };
753
753
  const processId = readProcessId(args);
754
754
  if (!processId) throw new Error('Background process stop requires processId.');
755
755
  const stopped = manager.stop(processId);
@@ -767,7 +767,7 @@ export async function runBackgroundProcessAction(context: CommandContext, args:
767
767
 
768
768
  if (action === 'wait') {
769
769
  const confirmationError = requireConfirmed(args, 'Background process wait');
770
- if (confirmationError) throw new Error(confirmationError);
770
+ if (confirmationError) return { status: 'needs_confirmation', reason: confirmationError, policy: 'Waiting on a background process requires confirm:true and explicitUserRequest.' };
771
771
  const processId = readProcessId(args);
772
772
  if (!processId) throw new Error('Background process wait requires processId.');
773
773
  const timeoutMs = clampTimeout(args.timeoutMs ?? readField(args, 'timeoutMs'), DEFAULT_WAIT_TIMEOUT_MS);