@pellux/goodvibes-tui 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +22 -3
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +2422 -1040
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/conversation-rendering.ts +2 -2
  8. package/src/core/conversation.ts +49 -2
  9. package/src/core/session-recovery.ts +24 -18
  10. package/src/core/system-message-router.ts +42 -26
  11. package/src/core/turn-event-wiring.ts +12 -16
  12. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +29 -24
  13. package/src/daemon/handlers/calendar/index.ts +316 -0
  14. package/src/daemon/handlers/context.ts +29 -0
  15. package/src/daemon/handlers/contracts.ts +77 -0
  16. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  17. package/src/daemon/handlers/drafts/index.ts +17 -0
  18. package/src/daemon/handlers/drafts/register.ts +331 -0
  19. package/src/daemon/handlers/email/config.ts +164 -0
  20. package/src/daemon/handlers/email/index.ts +43 -0
  21. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  22. package/src/daemon/handlers/email/runtime.ts +140 -0
  23. package/src/daemon/handlers/email/validation.ts +147 -0
  24. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  25. package/src/daemon/handlers/errors.ts +18 -0
  26. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  27. package/src/daemon/handlers/inbox/index.ts +211 -0
  28. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  29. package/src/daemon/{channels → handlers}/inbox/poller.ts +6 -5
  30. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  31. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +10 -11
  32. package/src/daemon/{channels → handlers}/inbox/providers/email.ts +2 -1
  33. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  34. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +4 -3
  35. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +11 -12
  36. package/src/daemon/handlers/index.ts +107 -0
  37. package/src/daemon/handlers/register.ts +161 -0
  38. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  39. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  40. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  41. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  42. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  43. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  44. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  45. package/src/daemon/handlers/remote/index.ts +119 -0
  46. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  47. package/src/daemon/handlers/remote/service.ts +191 -0
  48. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  49. package/src/daemon/handlers/routing/index.ts +261 -0
  50. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  51. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  52. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  53. package/src/daemon/handlers/triage/index.ts +57 -0
  54. package/src/daemon/handlers/triage/integration.ts +213 -0
  55. package/src/daemon/{triage → handlers/triage}/pipeline.ts +60 -71
  56. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  57. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  58. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  59. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  60. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  61. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  62. package/src/daemon/handlers/triage/types.ts +50 -0
  63. package/src/export/gist-uploader.ts +3 -1
  64. package/src/input/command-registry.ts +1 -0
  65. package/src/input/commands/cloudflare-runtime.ts +2 -1
  66. package/src/input/commands/guidance-runtime.ts +2 -4
  67. package/src/input/commands/health-runtime.ts +13 -7
  68. package/src/input/commands/knowledge.ts +2 -1
  69. package/src/input/commands/runtime-services.ts +40 -10
  70. package/src/input/handler-command-route.ts +1 -1
  71. package/src/input/handler-interactions.ts +2 -1
  72. package/src/input/handler-modal-routes.ts +2 -1
  73. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  74. package/src/input/handler-onboarding.ts +8 -8
  75. package/src/input/handler-ui-state.ts +1 -1
  76. package/src/input/tts-settings-actions.ts +2 -1
  77. package/src/main.ts +52 -59
  78. package/src/panels/agent-inspector-panel.ts +72 -2
  79. package/src/panels/agent-logs-panel.ts +127 -19
  80. package/src/panels/base-panel.ts +2 -1
  81. package/src/panels/builtin/agent.ts +3 -1
  82. package/src/panels/builtin/development.ts +1 -0
  83. package/src/panels/builtin/session.ts +1 -1
  84. package/src/panels/context-visualizer-panel.ts +24 -14
  85. package/src/panels/cost-tracker-panel.ts +7 -13
  86. package/src/panels/debug-panel.ts +11 -22
  87. package/src/panels/docs-panel.ts +14 -24
  88. package/src/panels/file-explorer-panel.ts +2 -1
  89. package/src/panels/file-preview-panel.ts +2 -1
  90. package/src/panels/git-panel.ts +10 -17
  91. package/src/panels/marketplace-panel.ts +2 -1
  92. package/src/panels/memory-panel.ts +2 -1
  93. package/src/panels/project-planning-panel.ts +5 -4
  94. package/src/panels/provider-health-panel.ts +56 -67
  95. package/src/panels/schedule-panel.ts +4 -7
  96. package/src/panels/session-browser-panel.ts +13 -21
  97. package/src/panels/skills-panel.ts +2 -1
  98. package/src/panels/tasks-panel.ts +2 -7
  99. package/src/panels/thinking-panel.ts +6 -11
  100. package/src/panels/token-budget-panel.ts +31 -15
  101. package/src/panels/tool-inspector-panel.ts +10 -18
  102. package/src/panels/work-plan-panel.ts +2 -1
  103. package/src/panels/wrfc-panel.ts +37 -35
  104. package/src/renderer/agent-detail-modal.ts +2 -2
  105. package/src/renderer/modal-utils.ts +0 -10
  106. package/src/renderer/process-modal.ts +2 -2
  107. package/src/runtime/bootstrap-command-context.ts +3 -0
  108. package/src/runtime/bootstrap-command-parts.ts +3 -1
  109. package/src/runtime/bootstrap-core.ts +31 -31
  110. package/src/runtime/bootstrap-shell.ts +1 -0
  111. package/src/runtime/onboarding/apply.ts +4 -3
  112. package/src/runtime/onboarding/snapshot.ts +4 -3
  113. package/src/runtime/process-lifecycle.ts +195 -0
  114. package/src/runtime/services.ts +52 -36
  115. package/src/runtime/wrfc-persistence.ts +20 -5
  116. package/src/shell/ui-openers.ts +3 -2
  117. package/src/verification/live-verifier.ts +4 -3
  118. package/src/version.ts +1 -1
  119. package/src/core/context-auto-compact.ts +0 -110
  120. package/src/daemon/calendar/index.ts +0 -52
  121. package/src/daemon/calendar/register.ts +0 -527
  122. package/src/daemon/channels/drafts/index.ts +0 -22
  123. package/src/daemon/channels/drafts/register.ts +0 -449
  124. package/src/daemon/channels/inbox/index.ts +0 -58
  125. package/src/daemon/channels/inbox/register.ts +0 -247
  126. package/src/daemon/channels/routing/index.ts +0 -39
  127. package/src/daemon/channels/routing/register.ts +0 -296
  128. package/src/daemon/email/index.ts +0 -68
  129. package/src/daemon/email/register.ts +0 -715
  130. package/src/daemon/operator/index.ts +0 -43
  131. package/src/daemon/operator/register-helper.ts +0 -150
  132. package/src/daemon/operator/surfaces.ts +0 -137
  133. package/src/daemon/operator/types.ts +0 -207
  134. package/src/daemon/remote/backends/index.ts +0 -34
  135. package/src/daemon/remote/index.ts +0 -74
  136. package/src/daemon/remote/register.ts +0 -411
  137. package/src/daemon/triage/index.ts +0 -59
  138. package/src/daemon/triage/integration.ts +0 -179
  139. package/src/daemon/triage/register.ts +0 -231
  140. package/src/daemon/triage/tagger.ts +0 -777
  141. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  142. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  143. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  144. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  145. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  146. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "0.33.38",
102
+ "@pellux/goodvibes-sdk": "0.35.0",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -15,6 +15,7 @@ import { getPackageVersion } from './help.ts';
15
15
  import { classifyProviderSetup } from './provider-classification.ts';
16
16
  import { buildCliServicePosture } from './service-posture.ts';
17
17
  import { REDACTED_VALUE, collectSensitiveConfigValues, isRedactedValue, redactConfig, redactSerializedSecrets } from './redaction.ts';
18
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
19
 
19
20
  interface BundleInspectSummary {
20
21
  readonly type: string;
@@ -43,7 +44,7 @@ function readJsonFile(path: string): { readonly ok: true; readonly value: Record
43
44
  try {
44
45
  return { ok: true, value: JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown> };
45
46
  } catch (error) {
46
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
47
+ return { ok: false, error: summarizeError(error) };
47
48
  }
48
49
  }
49
50
 
@@ -10,6 +10,7 @@ import { resolveRuntimeEndpointBinding } from './endpoints.ts';
10
10
  import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
11
11
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
12
12
  import { redactText } from './redaction.ts';
13
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
14
 
14
15
  export interface CliServiceRuntime {
15
16
  readonly configManager: ConfigManager;
@@ -326,7 +327,7 @@ function readLogPosture(path: string | undefined, tailBytes: number): CliService
326
327
  exists: true,
327
328
  size: 0,
328
329
  modifiedAt: null,
329
- readError: error instanceof Error ? error.message : String(error),
330
+ readError: summarizeError(error),
330
331
  };
331
332
  }
332
333
  }
@@ -320,10 +320,10 @@ export function appendConversationMessages(
320
320
 
321
321
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
322
322
  const message = messages[msgIdx];
323
- messageLineRegistry[msgIdx] = context.history.getLineCount();
324
323
  // absoluteIdx aligns the slice-relative loop counter with the absolute
325
- // message index used as the key in messageKindRegistry.
324
+ // message index used as the key in messageKindRegistry and messageLineRegistry.
326
325
  const absoluteIdx = msgIndexOffset + msgIdx;
326
+ messageLineRegistry[absoluteIdx] = context.history.getLineCount();
327
327
  if (message.role === 'user') {
328
328
  renderConversationUserMessage(context, message, width);
329
329
  } else if (message.role === 'assistant') {
@@ -64,7 +64,15 @@ export class ConversationManager extends SdkConversationManager {
64
64
  private collapseState: Map<string, boolean> = new Map();
65
65
  /** Block registry: track rendered blocks for copy/apply. */
66
66
  protected blockRegistry: BlockMeta[] = [];
67
- /** Message index -> first rendered line index in the history buffer. */
67
+ /**
68
+ * Absolute message index -> first rendered line index in the history buffer.
69
+ * Keyed by the message's ABSOLUTE position in the full (unsliced) snapshot —
70
+ * appendConversationMessages writes `messageLineRegistry[absoluteIdx]`, not the
71
+ * slice-relative loop counter — so transcript-event navigation
72
+ * (next/prevTranscriptEventLine), whose event.messageIndex is absolute, resolves
73
+ * correctly even after clearDisplay() renders only a tail slice. Holes (indices
74
+ * not currently rendered) read back as undefined and are filtered out by consumers.
75
+ */
68
76
  private messageLineRegistry: number[] = [];
69
77
  /**
70
78
  * Registry of rendered line indices for system messages whose kind is
@@ -91,6 +99,13 @@ export class ConversationManager extends SdkConversationManager {
91
99
  * Reset to 0 on startStreamingBlock and finalizeStreamingBlock.
92
100
  */
93
101
  private _lastStreamRenderMs = 0;
102
+ /**
103
+ * Terminal width captured when the current streaming block was anchored.
104
+ * If the width changes mid-stream, updateStreamingBlock forces a rebuild so
105
+ * streamingStartLine is re-anchored to the new buffer (see rebuildHistory).
106
+ * -1 when no stream is active.
107
+ */
108
+ private _streamWidth = -1;
94
109
  /**
95
110
  * Message index at the time of the last clearDisplay() call.
96
111
  * rebuildHistory() renders only messages at or after this index, so the
@@ -229,6 +244,7 @@ export class ConversationManager extends SdkConversationManager {
229
244
  // Reset the render throttle so the first delta always renders immediately.
230
245
  this.flushHistory();
231
246
  this.streamingStartLine = this.history.getLineCount();
247
+ this._streamWidth = this._getWidth();
232
248
  this._lastStreamRenderMs = 0;
233
249
  }
234
250
 
@@ -243,6 +259,13 @@ export class ConversationManager extends SdkConversationManager {
243
259
  // Throttle renderMarkdown to the 16ms render-coalescer cadence to avoid
244
260
  // O(n) parse overhead on every delta token during streaming.
245
261
  if (this.streamingStartLine >= 0) {
262
+ // If the terminal was resized mid-stream, force the pending width-change
263
+ // rebuild now. rebuildHistory() re-anchors streamingStartLine to the new
264
+ // buffer (see its streaming branch), so the incremental truncate below
265
+ // targets the correct offset instead of a stale one.
266
+ if (this._getWidth() !== this._streamWidth) {
267
+ this.flushHistory();
268
+ }
246
269
  const now = Date.now();
247
270
  if (now - this._lastStreamRenderMs >= 16) {
248
271
  this._lastStreamRenderMs = now;
@@ -261,6 +284,7 @@ export class ConversationManager extends SdkConversationManager {
261
284
  public override finalizeStreamingBlock(): void {
262
285
  super.finalizeStreamingBlock();
263
286
  this.streamingStartLine = -1;
287
+ this._streamWidth = -1;
264
288
  this._lastStreamRenderMs = 0;
265
289
  this.markDirty();
266
290
  }
@@ -367,12 +391,23 @@ export class ConversationManager extends SdkConversationManager {
367
391
  this.dirty = false;
368
392
 
369
393
  const snapshot = this.getMessageSnapshot();
394
+ // During streaming, the in-progress placeholder (always the last message) is
395
+ // rendered here as EMPTY; the incremental streaming path (updateStreamingBlock)
396
+ // owns its content. This keeps streamingStartLine valid across width-change
397
+ // rebuilds — otherwise the placeholder would be re-rendered with its content at
398
+ // the new width while streamingStartLine still pointed into the old buffer.
399
+ const lastMsg = snapshot[snapshot.length - 1];
400
+ const isStreaming = this.streamingStartLine >= 0 && lastMsg?.role === 'assistant';
401
+ const renderSnapshot = isStreaming
402
+ ? [...snapshot.slice(0, -1), { ...lastMsg, content: '' } as Message]
403
+ : snapshot;
404
+
370
405
  // When _displayFromMessageIndex > 0, clearDisplay() was called. Only render
371
406
  // messages added after the clear — the pre-clear history stays off-screen.
372
407
  // On a full rebuild (e.g. width change), reset the display-start to 0 so the
373
408
  // user can scroll back to the full history if needed.
374
409
  const displayStart = this._displayFromMessageIndex;
375
- const visibleSnapshot = displayStart > 0 ? snapshot.slice(displayStart) : snapshot;
410
+ const visibleSnapshot = displayStart > 0 ? renderSnapshot.slice(displayStart) : renderSnapshot;
376
411
 
377
412
  // Tool messages ARE rendered (as collapsed blocks); this filter is only
378
413
  // for determining whether to show the splash screen (tool-only messages
@@ -388,6 +423,18 @@ export class ConversationManager extends SdkConversationManager {
388
423
 
389
424
  this.appendMessages(visibleSnapshot, width, displayStart);
390
425
  this.appendedUpTo = snapshot.length;
426
+
427
+ if (isStreaming) {
428
+ // Re-anchor the streaming block to the freshly rebuilt buffer and re-render
429
+ // the in-progress content at the current width, mirroring startStreamingBlock.
430
+ this.streamingStartLine = this.history.getLineCount();
431
+ this._streamWidth = width;
432
+ this._lastStreamRenderMs = 0;
433
+ const streamingContent = lastMsg?.content;
434
+ if (typeof streamingContent === 'string' && streamingContent.length > 0) {
435
+ this.history.addLines(renderMarkdown(streamingContent, width, { isStreaming: true }));
436
+ }
437
+ }
391
438
  }
392
439
 
393
440
  /**
@@ -26,7 +26,8 @@
26
26
  * journal silently and return.
27
27
  * 3. If records are found, apply the final record's messages — each journal
28
28
  * record carries the full conversation snapshot at that moment, so the
29
- * last record by seq is the authoritative post-crash state.
29
+ * record with the newest timestamp is the authoritative post-crash state
30
+ * (resilient to seq collisions across re-inits onto a stale journal file).
30
31
  * 4. Rebuild the conversation history and call the snapshot writer so the
31
32
  * gap is durably closed before the user sees the restored conversation.
32
33
  * 5. Rotate the journal (it is no longer needed as a gap-filler).
@@ -64,8 +65,6 @@ export interface ReplayIntoConversationResult {
64
65
  readonly replayed: number;
65
66
  /** True if the journal tail was corrupt (quarantined). */
66
67
  readonly hadCorruptTail: boolean;
67
- /** True if the journal had an unrecognised schema version (quarantined). */
68
- readonly hadVersionMismatch: boolean;
69
68
  }
70
69
 
71
70
  // ─── Public API ─────────────────────────────────────────────────────────────
@@ -85,29 +84,36 @@ export function replayJournalIntoConversation(
85
84
  try {
86
85
  const { records, hadCorruptTail } = replayJournal(journalPath, snapshotTimestamp);
87
86
 
88
- // Detect version mismatch: replayJournal quarantines and returns
89
- // hadCorruptTail=true + 0 records when the header version is wrong.
90
- // We distinguish it from a genuine corrupt tail by checking whether the
91
- // journal file still exists (quarantine renames it away in both cases,
92
- // so we cannot inspect the header at this point). We surface both cases
93
- // through hadCorruptTail to the caller; hadVersionMismatch is derived
94
- // from it to give the caller a distinct notice option.
95
- const hadVersionMismatch = hadCorruptTail && records.length === 0;
96
-
97
87
  const journal = openTranscriptJournal(journalPath, sessionId);
98
88
 
99
89
  if (records.length === 0) {
100
90
  // Nothing to replay — rotate the (now-stale) journal silently.
101
91
  journal.rotate();
102
- return { replayed: 0, hadCorruptTail, hadVersionMismatch };
92
+ return { replayed: 0, hadCorruptTail };
103
93
  }
104
94
 
105
- // The last record (highest seq) holds the most recent full conversation
106
- // state captured before the crash. Apply it.
107
- const lastRecord = records[records.length - 1]!;
95
+ // Select the authoritative record by newest wall-clock timestamp (tie-broken
96
+ // by highest seq). Each record carries the full conversation snapshot at its
97
+ // moment, so the newest ts is by definition the most recent state. This is
98
+ // resilient to seq collisions that arise when a fresh process appends to a
99
+ // pre-existing, non-rotated journal: its records restart at seq 0 after the
100
+ // surviving old records, so "last by seq" could otherwise pick a stale one.
101
+ const lastRecord = records.reduce((newest, candidate) =>
102
+ candidate.ts > newest.ts ||
103
+ (candidate.ts === newest.ts && candidate.seq > newest.seq)
104
+ ? candidate
105
+ : newest,
106
+ );
108
107
  const replayedMessages = lastRecord.messages as ConversationMessageSnapshot[];
109
108
 
109
+ // Preserve the session identity (title/titleSource/branches/currentBranch)
110
+ // that the resume seam already hydrated onto the live conversation. Journal
111
+ // records carry only messages, so a bare fromJSON would blank the title and
112
+ // reset titleSource to the system default — and the next TURN_COMPLETED
113
+ // snapshot would then persist the empty title, making the loss permanent.
114
+ const preserved = conversation.toJSON();
110
115
  conversation.fromJSON({
116
+ ...preserved,
111
117
  messages: replayedMessages as never[],
112
118
  });
113
119
  conversation.rebuildHistory();
@@ -123,10 +129,10 @@ export function replayJournalIntoConversation(
123
129
  // Rotate the journal — it is no longer needed as a gap-filler.
124
130
  journal.rotate();
125
131
 
126
- return { replayed: records.length, hadCorruptTail, hadVersionMismatch: false };
132
+ return { replayed: records.length, hadCorruptTail };
127
133
  } catch {
128
134
  // Absolute last-resort guard — recovery must never crash a resume.
129
- return { replayed: 0, hadCorruptTail: false, hadVersionMismatch: false };
135
+ return { replayed: 0, hadCorruptTail: false };
130
136
  }
131
137
  }
132
138
 
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * SystemMessageRouter — routes system messages to the appropriate surfaces
3
- * based on priority.
3
+ * based on their kind and configured routing target.
4
4
  *
5
- * Two tiers:
6
- * - 'high' appears in both the main conversation AND the SystemMessagesPanel.
7
- * Use for: fatal errors, model/provider confirmations, session save/load,
8
- * compaction events (e.g. [Compaction] completed).
9
- * - 'low' appears in the SystemMessagesPanel only (panel-only routing).
10
- * Use for: scan results, provider discovery, plugin load/unload, feature
11
- * flag changes, tool execution status, permission decisions,
12
- * health/cascade events, debug/operational info.
5
+ * Delivery is resolved from the message KIND ('system' | 'wrfc' |
6
+ * 'operational'), which maps to a configurable routing target
7
+ * (ui.systemMessages / ui.wrfcMessages / ui.operationalMessages, each
8
+ * 'panel' | 'conversation' | 'both'). resolveSystemMessageDelivery() turns that
9
+ * target plus whether a panel is attached — into a { toPanel, toConversation }
10
+ * decision. The priority ('high' | 'low') only sets the panel emphasis; it does
11
+ * not by itself decide conversation delivery.
12
+ *
13
+ * Critical override: errors, provider failovers, and compaction/context notices
14
+ * (see FORCE_CONVERSATION_PREFIXES) ALWAYS also reach the main conversation,
15
+ * regardless of target, so the user never has to open the SystemMessagesPanel
16
+ * to discover them.
13
17
  *
14
18
  * Usage:
15
19
  * ```ts
@@ -18,9 +22,9 @@
18
22
  * router.routeSystemMessage('[Session] Saved session abc123', 'high');
19
23
  * ```
20
24
  *
21
- * The router can also be used as a drop-in replacement for
22
- * conversation.addSystemMessage() calls — it classifies messages by priority
23
- * automatically when using routeAuto().
25
+ * routeAuto() can be used as a drop-in replacement for
26
+ * conversation.addSystemMessage(): it classifies the message kind and priority
27
+ * automatically before routing.
24
28
  *
25
29
  * @remarks
26
30
  * This router handles system messages (operational status, errors). It is
@@ -28,8 +32,6 @@
28
32
  * notifications with policy-based routing.
29
33
  */
30
34
 
31
- import { getConfigSnapshot } from '../config/index.ts';
32
- import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
33
35
  import type { ConversationManager } from './conversation';
34
36
  import type { SystemMessagesPanel, SystemMessagePriority } from '../panels/system-messages-panel.ts';
35
37
  import {
@@ -46,14 +48,23 @@ export type {
46
48
  SystemMessageTarget,
47
49
  } from '@/runtime/index.ts';
48
50
 
49
- function targetForKind(
50
- configManager: Pick<ConfigManager, 'getRaw'>,
51
- kind: SystemMessageKind,
52
- ): SystemMessageTarget {
53
- const ui = getConfigSnapshot(configManager).ui;
54
- if (kind === 'wrfc') return ui.wrfcMessages;
55
- if (kind === 'operational') return ui.operationalMessages;
56
- return ui.systemMessages;
51
+ /**
52
+ * Message categories that are operationally critical enough that the user must
53
+ * see them inline in the main conversation, regardless of the configured
54
+ * routing target (ui.systemMessages defaults to panel-only). Errors, provider
55
+ * failovers, and compaction/context notices fall here: a user should never have
56
+ * to open the SystemMessagesPanel to discover that a turn errored, a provider
57
+ * failed over, or the context was compacted.
58
+ *
59
+ * Detection is by the stable message prefix tag, mirroring how the SDK
60
+ * classifiers key off message content. This deliberately does NOT force every
61
+ * high-priority message inline (model/provider/session confirmations still
62
+ * honour the configured target), so the routing-target config stays meaningful.
63
+ */
64
+ const FORCE_CONVERSATION_PREFIXES = ['[Error]', '[Failover]', '[Compaction]', '[Context]'] as const;
65
+
66
+ function mustReachConversation(message: string): boolean {
67
+ return FORCE_CONVERSATION_PREFIXES.some((prefix) => message.startsWith(prefix));
57
68
  }
58
69
 
59
70
  // ---------------------------------------------------------------------------
@@ -74,10 +85,11 @@ export class SystemMessageRouter {
74
85
  // ── Public API ────────────────────────────────────────────────────────────
75
86
 
76
87
  /**
77
- * Route a system message with explicit priority.
88
+ * Route a system message.
78
89
  *
79
- * - 'high': delivered to both conversation AND panel.
80
- * - 'low': delivered to panel only (conversation is not touched).
90
+ * Delivery is resolved from the kind and its configured target; priority only
91
+ * sets panel emphasis. Critical notices (errors/failover/compaction, see
92
+ * FORCE_CONVERSATION_PREFIXES) are additionally forced into the conversation.
81
93
  *
82
94
  * @param message - Message text.
83
95
  * @param priority - 'high' | 'low'.
@@ -94,7 +106,11 @@ export class SystemMessageRouter {
94
106
  if (delivery.toPanel) {
95
107
  this.panel?.push(message, priority);
96
108
  }
97
- if (delivery.toConversation) {
109
+ // Critical notices (errors, failover, compaction) must surface inline even
110
+ // when the configured target is panel-only — otherwise a user without the
111
+ // SystemMessagesPanel open would never see them.
112
+ const toConversation = delivery.toConversation || mustReachConversation(message);
113
+ if (toConversation) {
98
114
  // addTypedSystemMessage threads the kind into the conversation so the
99
115
  // renderer can use kind-based navigability instead of substring matching.
100
116
  this.conversation.addTypedSystemMessage(message, kind);
@@ -1,6 +1,5 @@
1
1
  import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
2
  import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
- import { maybeAutoCompact } from './context-auto-compact.ts';
4
3
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
4
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
5
  import type { HookDispatcher, HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
@@ -80,17 +79,21 @@ export interface WireTurnEventHandlersResult {
80
79
  * Responsibilities:
81
80
  * - Auto-save conversation to persistent store after each LLM turn
82
81
  * - Fire the Lifecycle:session:save hook
83
- * - Trigger auto-compact when context usage exceeds the configured threshold
84
82
  * - Refresh git status after turns and tool results
85
83
  *
84
+ * Note: auto-compaction is intentionally NOT performed here. The SDK
85
+ * Orchestrator already runs post-turn context maintenance every turn
86
+ * (handlePostTurnContextMaintenance) using the resolved context window and
87
+ * its own getAutoCompactDecision; re-triggering it from the TUI duplicated
88
+ * that work and raced the SDK's compaction on the shared message array.
89
+ *
86
90
  * Returns refreshGit (callable externally) and unsubs (push into parent unsubs).
87
91
  */
88
92
  export function wireTurnEventHandlers(
89
93
  options: WireTurnEventHandlersOptions,
90
94
  ): WireTurnEventHandlersResult {
91
95
  const {
92
- events, conversation, runtime, orchestrator, configManager,
93
- providerRegistry, systemMessageRouter, hookDispatcher,
96
+ events, conversation, runtime, configManager, hookDispatcher,
94
97
  workingDir, homeDirectory, sessionManager, gitStatusProvider,
95
98
  lastGitInfoRef, buildSessionContinuityHints, render, webhookNotifier,
96
99
  _clock = Date.now,
@@ -161,18 +164,11 @@ export function wireTurnEventHandlers(
161
164
  } catch { /* best-effort */ }
162
165
  logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) });
163
166
  }
164
- // Auto-compact: check context usage and compact if threshold exceeded
165
- const currentModelForCompact = providerRegistry.getCurrentModel();
166
- maybeAutoCompact({
167
- configManager: configManager as Parameters<typeof maybeAutoCompact>[0]['configManager'],
168
- conversation,
169
- providerRegistry: providerRegistry as Parameters<typeof maybeAutoCompact>[0]['providerRegistry'],
170
- systemMessageRouter: systemMessageRouter as Parameters<typeof maybeAutoCompact>[0]['systemMessageRouter'],
171
- model: runtime.model,
172
- provider: runtime.provider,
173
- lastInputTokens: orchestrator.lastInputTokens,
174
- contextWindow: currentModelForCompact.contextWindow,
175
- }).catch((err: unknown) => logger.debug('maybeAutoCompact error', { error: summarizeError(err) }));
167
+ // Auto-compaction is owned by the SDK Orchestrator's post-turn maintenance
168
+ // (handlePostTurnContextMaintenance), which runs on every turn against the
169
+ // resolved context window (getContextWindowForModel) via the SDK's
170
+ // getAutoCompactDecision (percentage threshold + 15k safety buffer +
171
+ // small-window handling). The TUI must not independently re-trigger it.
176
172
  refreshGit();
177
173
  }));
178
174
 
@@ -1,8 +1,8 @@
1
- // CalDAV client for the calendar operator surface.
1
+ // CalDAV client for the calendar handler surface.
2
2
  //
3
3
  // Authenticates to the user's CalDAV endpoint using credentials resolved from
4
4
  // the daemon credential store (config keys under `surfaces.calendar.*`). It
5
- // implements the CalDAV/WebDAV verbs the operator methods need:
5
+ // implements the CalDAV/WebDAV verbs the gateway methods need:
6
6
  // - REPORT (calendar-query) -> list / get events
7
7
  // - PUT -> create / import events
8
8
  // - REPORT (multiget) / GET -> export a collection
@@ -16,9 +16,9 @@
16
16
  // * Event hrefs are returned to callers as opaque relative identifiers, never
17
17
  // as fully-qualified authenticated URLs.
18
18
 
19
- import type { OperatorContext } from '../operator/types.ts';
20
- import { OperatorError } from '../operator/types.ts';
21
- import { createDaemonCredentialStore } from '../operator/credential-store.ts';
19
+ import { HandlerError } from '../errors.ts';
20
+ import type { HandlerContext } from '../context.ts';
21
+ import type { DaemonCredentialStore } from '../credentials.ts';
22
22
  import {
23
23
  generateCalendar,
24
24
  generateICS,
@@ -26,6 +26,7 @@ import {
26
26
  type GenerateICalInput,
27
27
  type ParsedICalEvent,
28
28
  } from './ics.ts';
29
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // Configuration
@@ -112,12 +113,15 @@ export interface CalDavClient {
112
113
  exportIcs(opts: { calendarId?: string; from?: string; to?: string }): Promise<ExportResult>;
113
114
  }
114
115
 
116
+ /** Subset of the handler context the CalDAV config resolver needs. */
117
+ export type CalDavConfigContext = Pick<HandlerContext, 'configManager' | 'credentials'>;
118
+
115
119
  // ---------------------------------------------------------------------------
116
120
  // Config resolution
117
121
  // ---------------------------------------------------------------------------
118
122
 
119
123
  function readConfigString(
120
- ctx: Pick<OperatorContext, 'configManager'>,
124
+ ctx: Pick<HandlerContext, 'configManager'>,
121
125
  key: string,
122
126
  ): string | undefined {
123
127
  const value = ctx.configManager.get(key as never);
@@ -147,16 +151,17 @@ function parseCollectionMap(raw: string | undefined): Record<string, string> {
147
151
  * Resolve the full CalDAV configuration from config + credential store.
148
152
  * The password is resolved from the daemon credential store — either as a
149
153
  * goodvibes://secrets/ reference stored in config, or the config-key-derived
150
- * secret. Throws OperatorError when the surface is not configured.
154
+ * secret. Throws HandlerError when the surface is not configured. The resolved
155
+ * password is held only in memory and never returned to callers.
151
156
  */
152
157
  export async function resolveCalDavConfig(
153
- ctx: Pick<OperatorContext, 'configManager' | 'secrets'>,
158
+ ctx: CalDavConfigContext,
154
159
  ): Promise<CalDavConfig> {
155
- const credentials = createDaemonCredentialStore(ctx.secrets);
160
+ const credentials: DaemonCredentialStore = ctx.credentials;
156
161
  const baseUrl = readConfigString(ctx, CFG_URL);
157
162
  const username = readConfigString(ctx, CFG_USER);
158
163
  if (!baseUrl || !username) {
159
- throw new OperatorError(
164
+ throw new HandlerError(
160
165
  'CalDAV is not configured. Set surfaces.calendar.caldavUrl and surfaces.calendar.caldavUser.',
161
166
  'CALENDAR_NOT_CONFIGURED',
162
167
  412,
@@ -174,7 +179,7 @@ export async function resolveCalDavConfig(
174
179
  password = await credentials.resolveConfigSecret(CFG_PASSWORD);
175
180
  }
176
181
  if (!password) {
177
- throw new OperatorError(
182
+ throw new HandlerError(
178
183
  'CalDAV password is not available in the credential store.',
179
184
  'CALENDAR_CREDENTIALS_MISSING',
180
185
  412,
@@ -204,7 +209,7 @@ function joinUrl(base: string, segment: string): string {
204
209
  /** Extract the scheme+host origin from an absolute URL (no trailing slash). */
205
210
  export function originOf(url: string): string {
206
211
  const match = /^(https?:\/\/[^/]+)/i.exec(url.trim());
207
- return match ? match[1] : stripTrailingSlash(url);
212
+ return match ? match[1]! : stripTrailingSlash(url);
208
213
  }
209
214
 
210
215
  /**
@@ -227,7 +232,7 @@ function resolveCollectionUrl(baseUrl: string, path: string): string {
227
232
  export function toRelativeHref(href: string): string {
228
233
  const trimmed = href.trim();
229
234
  const schemeMatch = /^https?:\/\/[^/]+(\/.*)$/i.exec(trimmed);
230
- if (schemeMatch) return schemeMatch[1];
235
+ if (schemeMatch) return schemeMatch[1]!;
231
236
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
232
237
  }
233
238
 
@@ -302,7 +307,7 @@ function propfindCalendarsBody(): string {
302
307
  function toCalDavStamp(iso: string): string {
303
308
  const date = new Date(iso);
304
309
  if (Number.isNaN(date.getTime())) {
305
- throw new OperatorError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
310
+ throw new HandlerError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
306
311
  }
307
312
  const pad = (n: number): string => (n < 10 ? `0${n}` : String(n));
308
313
  return (
@@ -353,7 +358,7 @@ export function parseMultiStatus(xml: string): MultiStatusEntry[] {
353
358
  const responseRe = /<(?:[a-zA-Z0-9]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/gi;
354
359
  let match: RegExpExecArray | null;
355
360
  while ((match = responseRe.exec(xml)) !== null) {
356
- const block = match[1];
361
+ const block = match[1]!;
357
362
  const hrefRaw = tagContent(block, 'href');
358
363
  if (!hrefRaw) continue;
359
364
  const href = decodeXmlEntities(hrefRaw.trim());
@@ -425,18 +430,18 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
425
430
  // (e.g. "getaddrinfo ENOTFOUND cal.example.com") leaks the CalDAV
426
431
  // hostname/URL into caller-visible output (importIcs errors[]).
427
432
  // Never include creds/URL/host or raw fetch detail.
428
- throw new OperatorError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
433
+ throw new HandlerError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
429
434
  }
430
435
  const text = await response.text();
431
436
  if (!response.ok) {
432
- // Map auth/permission/not-found to operator errors. Never include creds/URL.
437
+ // Map auth/permission/not-found to handler errors. Never include creds/URL.
433
438
  const status = response.status;
434
439
  const code = status === 401 || status === 403
435
440
  ? 'CALENDAR_AUTH_FAILED'
436
441
  : status === 404
437
442
  ? 'CALENDAR_NOT_FOUND'
438
443
  : 'CALENDAR_REQUEST_FAILED';
439
- throw new OperatorError(
444
+ throw new HandlerError(
440
445
  `CalDAV server returned HTTP ${status}.`,
441
446
  code,
442
447
  status >= 400 && status < 500 ? status : 502,
@@ -472,7 +477,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
472
477
  const rel = toRelativeHref(entry.href);
473
478
  // Derive a stable logical id from the last path segment.
474
479
  const segments = rel.split('/').filter((s) => s.length > 0);
475
- const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]) : config.defaultCalendarId;
480
+ const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]!) : config.defaultCalendarId;
476
481
  if (seen.has(logical)) continue;
477
482
  seen.add(logical);
478
483
  calendars.push({ calendarId: logical, displayName: entry.displayName ?? logical });
@@ -509,7 +514,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
509
514
  single = await request(resourceUrl, 'GET', undefined, { Depth: '0' });
510
515
  } catch (error) {
511
516
  // A 404 maps to "not found"; any other failure propagates.
512
- if (error instanceof OperatorError && error.code === 'CALENDAR_NOT_FOUND') {
517
+ if (error instanceof HandlerError && error.code === 'CALENDAR_NOT_FOUND') {
513
518
  return null;
514
519
  }
515
520
  throw error;
@@ -517,7 +522,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
517
522
  const parsed = parseICS(single.text);
518
523
  if (parsed.length === 0) return null;
519
524
  const relHref = toRelativeHref(eventId);
520
- return { ...parsed[0], href: relHref, calendarId: id };
525
+ return { ...parsed[0]!, href: relHref, calendarId: id };
521
526
  }
522
527
 
523
528
  // Strategy 2 — bare UID: ask the server for ONLY the matching resource via
@@ -566,7 +571,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
566
571
  const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
567
572
  const parsed = parseICS(icsContent);
568
573
  if (parsed.length === 0) {
569
- throw new OperatorError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
574
+ throw new HandlerError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
570
575
  }
571
576
  const collectionUrl = collectionUrlFor(id);
572
577
  const eventIds: string[] = [];
@@ -582,7 +587,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
582
587
  eventIds.push(toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath)));
583
588
  imported += 1;
584
589
  } catch (error) {
585
- const message = error instanceof OperatorError ? error.message : error instanceof Error ? error.message : String(error);
590
+ const message = error instanceof HandlerError ? error.message : summarizeError(error);
586
591
  errors.push(`${uid}: ${message}`);
587
592
  }
588
593
  }
@@ -646,7 +651,7 @@ function parsedToGenerateInput(event: ParsedICalEvent, uid: string): GenerateICa
646
651
  description: event.description,
647
652
  location: event.location,
648
653
  // Re-emit attendees by their raw value so import preserves the original
649
- // addressing; display-name-only redaction applies to operator responses,
654
+ // addressing; display-name-only redaction applies to handler responses,
650
655
  // not to server-side payloads.
651
656
  attendees: event.attendees.map((a) => a.rawValue),
652
657
  organizer: event.organizerRaw,