@pellux/goodvibes-tui 0.21.0 → 0.23.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.
- package/CHANGELOG.md +45 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/cli/completions/generate.ts +4 -8
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management-utils.ts +352 -0
- package/src/cli/management.ts +36 -334
- package/src/cli/parser.ts +17 -0
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/types.ts +2 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/core/context-auto-compact.ts +110 -0
- package/src/core/conversation-rendering.ts +5 -2
- package/src/core/conversation-types.ts +24 -0
- package/src/core/conversation.ts +7 -12
- package/src/core/stream-event-wiring.ts +125 -7
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/channel-runtime.ts +139 -0
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/runtime-services.ts +30 -1
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +54 -4
- package/src/input/commands.ts +2 -2
- package/src/input/handler-modal-routes.ts +37 -0
- package/src/input/handler-modal-token-routes.ts +19 -5
- package/src/input/handler-onboarding.ts +18 -0
- package/src/input/handler.ts +1 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +77 -3
- package/src/input/settings-modal-mutations.ts +3 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +55 -13
- package/src/main.ts +58 -50
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/development.ts +1 -0
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/session-maintenance.ts +66 -15
- package/src/renderer/agent-detail-modal.ts +107 -3
- package/src/renderer/compaction-history-modal.ts +55 -0
- package/src/renderer/compaction-preview.ts +146 -0
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/settings-modal-helpers.ts +2 -2
- package/src/renderer/settings-modal.ts +14 -3
- package/src/renderer/shell-surface.ts +10 -0
- package/src/runtime/bootstrap-command-parts.ts +4 -0
- package/src/runtime/bootstrap-core.ts +116 -0
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +7 -0
- package/src/runtime/services.ts +6 -1
- package/src/utils/browser.ts +29 -0
- package/src/version.ts +1 -1
- package/src/panels/knowledge-panel.ts +0 -343
|
@@ -8,18 +8,25 @@ import { formatDuration } from './modal-utils.ts';
|
|
|
8
8
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
9
9
|
import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
|
|
10
10
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
|
+
import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
|
|
12
|
+
import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } from '../panels/agent-inspector-shared.ts';
|
|
11
13
|
|
|
12
14
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
13
15
|
|
|
14
16
|
const MAX_LOG_ENTRIES = 10;
|
|
15
17
|
const AGENT_ID_DISPLAY_LENGTH = 16;
|
|
16
18
|
|
|
19
|
+
// MODAL_TERMINAL_STATUSES and MODAL_STALL_THRESHOLD_MS are re-exported aliases
|
|
20
|
+
// from agent-inspector-shared.ts (imported above alongside ConfirmState).
|
|
21
|
+
|
|
17
22
|
export interface AgentDetailModalDeps {
|
|
18
|
-
readonly agentManager: Pick<AgentManager, 'getStatus'>;
|
|
23
|
+
readonly agentManager: Pick<AgentManager, 'getStatus' | 'list'>;
|
|
19
24
|
readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
|
|
20
25
|
readonly sessionLogPathResolver: (agentId: string) => string;
|
|
21
26
|
/** Optional — when supplied, constraint data from the agent's WRFC chain is shown (SDK 0.23.0). */
|
|
22
27
|
readonly wrfcController?: Pick<WrfcController, 'getChain'>;
|
|
28
|
+
/** Cancel the agent by id using the same orphan-free path as WRFC. Returns true if cancelled. */
|
|
29
|
+
readonly cancelAgent: (agentId: string) => boolean;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
// ─── AgentDetailModal ─────────────────────────────────────────────────────────
|
|
@@ -39,6 +46,9 @@ export class AgentDetailModal {
|
|
|
39
46
|
public logEntries: Record<string, unknown>[] = [];
|
|
40
47
|
public logTotal = 0;
|
|
41
48
|
|
|
49
|
+
/** Pending cancel confirmation. Subject is the agent id to cancel. */
|
|
50
|
+
public confirmCancel: ConfirmState<string> | null = null;
|
|
51
|
+
|
|
42
52
|
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
53
|
private onRefresh: (() => void) | null = null;
|
|
44
54
|
|
|
@@ -67,12 +77,88 @@ export class AgentDetailModal {
|
|
|
67
77
|
this.agentId = null;
|
|
68
78
|
this.logEntries = [];
|
|
69
79
|
this.logTotal = 0;
|
|
80
|
+
this.confirmCancel = null;
|
|
70
81
|
if (this.refreshTimer) {
|
|
71
82
|
clearInterval(this.refreshTimer);
|
|
72
83
|
this.refreshTimer = null;
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Handle a key press while the modal is active.
|
|
89
|
+
* Must be called BEFORE the Esc handler closes the modal.
|
|
90
|
+
*
|
|
91
|
+
* Routes:
|
|
92
|
+
* - 'c' initiates cancel confirm (if agent is non-terminal)
|
|
93
|
+
* - confirm keys (Enter/y/n/Esc) are forwarded to handleConfirmInput
|
|
94
|
+
*
|
|
95
|
+
* Returns true when the key was consumed (caller should NOT propagate).
|
|
96
|
+
*/
|
|
97
|
+
handleKey(key: string): boolean {
|
|
98
|
+
if (!this.active) return false;
|
|
99
|
+
|
|
100
|
+
if (this.confirmCancel) {
|
|
101
|
+
const result = handleConfirmInput(this.confirmCancel, key);
|
|
102
|
+
if (result === 'confirmed') {
|
|
103
|
+
if (this.agentId) {
|
|
104
|
+
const rec = this.deps.agentManager.getStatus(this.agentId);
|
|
105
|
+
if (rec && !MODAL_TERMINAL_STATUSES.has(rec.status)) {
|
|
106
|
+
this.deps.cancelAgent(rec.id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.confirmCancel = null;
|
|
110
|
+
this.onRefresh?.();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (result === 'cancelled') {
|
|
114
|
+
this.confirmCancel = null;
|
|
115
|
+
this.onRefresh?.();
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// absorbed — key swallowed while confirm is pending
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (key === 'c') {
|
|
123
|
+
if (this.agentId) {
|
|
124
|
+
const rec = this.deps.agentManager.getStatus(this.agentId);
|
|
125
|
+
if (rec && !MODAL_TERMINAL_STATUSES.has(rec.status)) {
|
|
126
|
+
const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
|
|
127
|
+
this.confirmCancel = { subject: rec.id, label };
|
|
128
|
+
this.onRefresh?.();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Non-cancellable — absorb key silently
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns whether the current agent is considered stalled.
|
|
141
|
+
* Non-terminal agent with elapsed time exceeding MODAL_STALL_THRESHOLD_MS.
|
|
142
|
+
*/
|
|
143
|
+
isCurrentAgentStalled(): boolean {
|
|
144
|
+
if (!this.agentId) return false;
|
|
145
|
+
const rec = this.deps.agentManager.getStatus(this.agentId);
|
|
146
|
+
if (!rec || MODAL_TERMINAL_STATUSES.has(rec.status)) return false;
|
|
147
|
+
return (Date.now() - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Count of all stalled agents across the agentManager list.
|
|
152
|
+
* Non-terminal agents with elapsed time >= MODAL_STALL_THRESHOLD_MS.
|
|
153
|
+
*/
|
|
154
|
+
getStalledAgentCount(): number {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
return this.deps.agentManager.list().filter(rec => {
|
|
157
|
+
if (MODAL_TERMINAL_STATUSES.has(rec.status)) return false;
|
|
158
|
+
return (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
|
|
159
|
+
}).length;
|
|
160
|
+
}
|
|
161
|
+
|
|
76
162
|
async loadLog(): Promise<void> {
|
|
77
163
|
if (!this.agentId) { this.logEntries = []; this.logTotal = 0; return; }
|
|
78
164
|
try {
|
|
@@ -161,7 +247,8 @@ export function renderAgentDetailModal(
|
|
|
161
247
|
const modelStr = rec.model ? `${rec.provider ?? ''}/${rec.model}` : (rec.provider ?? '(default)');
|
|
162
248
|
sections.push({ type: 'text', content: `Template : ${rec.template}` });
|
|
163
249
|
sections.push({ type: 'text', content: `Model : ${modelStr}` });
|
|
164
|
-
|
|
250
|
+
const isStalled = !MODAL_TERMINAL_STATUSES.has(rec.status) && (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
|
|
251
|
+
sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? ' [STALLED — 5+ min no activity]' : ''}` });
|
|
165
252
|
sections.push({ type: 'text', content: `Duration : ${formatDuration(elapsedMs)}` });
|
|
166
253
|
sections.push({ type: 'separator' });
|
|
167
254
|
|
|
@@ -321,12 +408,29 @@ export function renderAgentDetailModal(
|
|
|
321
408
|
}
|
|
322
409
|
}
|
|
323
410
|
|
|
411
|
+
// Cancel confirm overlay (when pending)
|
|
412
|
+
const cancellable = !MODAL_TERMINAL_STATUSES.has(rec.status);
|
|
413
|
+
if (modal.confirmCancel) {
|
|
414
|
+
sections.push({ type: 'separator' });
|
|
415
|
+
sections.push({
|
|
416
|
+
type: 'text',
|
|
417
|
+
content: `Cancel agent "${modal.confirmCancel.label}"?`,
|
|
418
|
+
style: { fg: '#f59e0b' },
|
|
419
|
+
});
|
|
420
|
+
sections.push({
|
|
421
|
+
type: 'text',
|
|
422
|
+
content: 'y / Enter confirm n / Esc cancel',
|
|
423
|
+
style: { dim: true },
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const cancelHint = cancellable ? '[c] Cancel ' : '';
|
|
324
428
|
return ModalFactory.createModal({
|
|
325
429
|
title: `Agent: ${rec.id.slice(0, AGENT_ID_DISPLAY_LENGTH)}`,
|
|
326
430
|
width: metrics.boxWidth,
|
|
327
431
|
margin: metrics.margin,
|
|
328
432
|
targetContentRows,
|
|
329
433
|
sections,
|
|
330
|
-
hints: ['[Esc] Close'],
|
|
434
|
+
hints: [cancelHint + '[Esc] Close'],
|
|
331
435
|
}, width);
|
|
332
436
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction history text builder.
|
|
3
|
+
*
|
|
4
|
+
* Renders a read-only list of past compaction events sourced from the SDK's
|
|
5
|
+
* module-level compaction event log (`getCompactionEvents()`).
|
|
6
|
+
*
|
|
7
|
+
* The SDK records CompactionEvent data (timestamps, token counts,
|
|
8
|
+
* trigger, message counts) but does not expose a snapshot restore API.
|
|
9
|
+
* Restore is list-only; users can view what compactions ran but cannot roll back.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
|
|
13
|
+
import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
|
|
14
|
+
|
|
15
|
+
// ─── formatCompactionEvent ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function formatCompactionEvent(ev: CompactionEvent, n: number): string {
|
|
18
|
+
const date = new Date(ev.timestamp);
|
|
19
|
+
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
20
|
+
const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
|
|
21
|
+
const savingsPct = ev.tokensBeforeEstimate > 0
|
|
22
|
+
? Math.round((savings / ev.tokensBeforeEstimate) * 100)
|
|
23
|
+
: 0;
|
|
24
|
+
const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
|
|
25
|
+
return (
|
|
26
|
+
`#${n} ${timeStr} [${trigger}] ` +
|
|
27
|
+
`${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
|
|
28
|
+
`~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
|
|
29
|
+
`saved ${savingsPct}%`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtN(n: number): string {
|
|
34
|
+
return n.toLocaleString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a plain-text compaction history summary suitable for ctx.print().
|
|
39
|
+
* Useful as the output of /compact-history when not in overlay mode.
|
|
40
|
+
*/
|
|
41
|
+
export function buildCompactionHistoryText(): string {
|
|
42
|
+
const events = getCompactionEvents();
|
|
43
|
+
if (events.length === 0) {
|
|
44
|
+
return '[Context] No compactions recorded this session. (Restore is not available — the SDK does not yet expose a snapshot restore API.)';
|
|
45
|
+
}
|
|
46
|
+
const lines: string[] = [
|
|
47
|
+
`[Context] Compaction history (${events.length} total, most recent first):`,
|
|
48
|
+
];
|
|
49
|
+
const ordered = [...events].reverse();
|
|
50
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
51
|
+
lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
|
|
52
|
+
}
|
|
53
|
+
lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction preview and after-notice builders.
|
|
3
|
+
*
|
|
4
|
+
* Pre-compact: shows an honest estimate of what compaction will do
|
|
5
|
+
* (message count and token estimate before → after). The SDK has no dry-run
|
|
6
|
+
* API; we derive the after-estimate from the DEFAULT_COMPACTION_CONFIG
|
|
7
|
+
* totalCeiling (6500 tokens) — clearly labelled as an ESTIMATE.
|
|
8
|
+
*
|
|
9
|
+
* Post-compact: shows a before/after notice using the CompactionEvent
|
|
10
|
+
* data returned by compactMessages(), which contains the real
|
|
11
|
+
* tokensBeforeEstimate and tokensAfterEstimate figures.
|
|
12
|
+
*
|
|
13
|
+
* Honest wording policy:
|
|
14
|
+
* - Pre-compact notice says "estimate" every time; never claims certainty.
|
|
15
|
+
* - Post-compact notice uses "~N" prefix on every token figure.
|
|
16
|
+
* - Pinned session memories that survive are mentioned by count.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
|
|
20
|
+
import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
|
|
21
|
+
import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
|
|
25
|
+
* Kept local so we don't take a runtime dependency on the internal config object.
|
|
26
|
+
* Used only for the pre-compact ESTIMATE; the real figure comes from CompactionEvent.
|
|
27
|
+
*/
|
|
28
|
+
const COMPACTION_OUTPUT_CEILING_ESTIMATE = 6500;
|
|
29
|
+
|
|
30
|
+
export interface CompactionPreviewOptions {
|
|
31
|
+
/** Messages currently in the conversation. */
|
|
32
|
+
readonly messages: readonly ProviderMessage[];
|
|
33
|
+
/** Context window size for the current model (0 if unknown). */
|
|
34
|
+
readonly contextWindow: number;
|
|
35
|
+
/** Number of session memories that will survive compaction. */
|
|
36
|
+
readonly pinnedMemoryCount: number;
|
|
37
|
+
/** Whether this is triggered automatically or manually. */
|
|
38
|
+
readonly trigger: 'auto' | 'manual';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CompactionAfterOptions {
|
|
42
|
+
/** The CompactionEvent returned by the SDK after compaction completes. */
|
|
43
|
+
readonly event: CompactionEvent;
|
|
44
|
+
/** Number of session memories that survived compaction. */
|
|
45
|
+
readonly pinnedMemoryCount: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the pre-compaction notice string.
|
|
50
|
+
*
|
|
51
|
+
* Returned as a plain string intended for `ctx.print()` or `systemMessageRouter`.
|
|
52
|
+
* Always labelled as an estimate; uses the SDK totalCeiling as the after-estimate.
|
|
53
|
+
*/
|
|
54
|
+
export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
|
|
55
|
+
const { messages, contextWindow, pinnedMemoryCount, trigger } = opts;
|
|
56
|
+
const msgCount = messages.length;
|
|
57
|
+
const tokensBefore = estimateConversationTokens(messages as ProviderMessage[]);
|
|
58
|
+
const tokensAfterEstimate = COMPACTION_OUTPUT_CEILING_ESTIMATE;
|
|
59
|
+
|
|
60
|
+
const contextStr = contextWindow > 0
|
|
61
|
+
? ` (${Math.round((tokensBefore / contextWindow) * 100)}% of ${fmtN(contextWindow)} context window)`
|
|
62
|
+
: '';
|
|
63
|
+
|
|
64
|
+
const pinStr = pinnedMemoryCount > 0
|
|
65
|
+
? ` ${pinnedMemoryCount} pinned session memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} will be preserved.`
|
|
66
|
+
: '';
|
|
67
|
+
|
|
68
|
+
const triggerStr = trigger === 'auto' ? 'Auto-compacting' : 'Compacting';
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
`[Context] ${triggerStr} conversation: ~${fmtN(tokensBefore)} tokens across ${msgCount} message${msgCount === 1 ? '' : 's'}${contextStr}.` +
|
|
72
|
+
` Estimated result: ~${fmtN(tokensAfterEstimate)} tokens (estimate — actual depends on content).` +
|
|
73
|
+
(pinStr ? ` ${pinStr.trim()}` : '')
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the post-compaction before/after notice string.
|
|
79
|
+
*
|
|
80
|
+
* Uses the real CompactionEvent figures (not estimates) for both before and
|
|
81
|
+
* after token counts. The trigger field controls wording.
|
|
82
|
+
*/
|
|
83
|
+
export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
|
|
84
|
+
const { event, pinnedMemoryCount } = opts;
|
|
85
|
+
const {
|
|
86
|
+
messagesBeforeCompaction,
|
|
87
|
+
messagesAfterCompaction,
|
|
88
|
+
tokensBeforeEstimate,
|
|
89
|
+
tokensAfterEstimate,
|
|
90
|
+
trigger,
|
|
91
|
+
} = event;
|
|
92
|
+
|
|
93
|
+
const savings = Math.max(0, tokensBeforeEstimate - tokensAfterEstimate);
|
|
94
|
+
const savingsPct = tokensBeforeEstimate > 0
|
|
95
|
+
? Math.round((savings / tokensBeforeEstimate) * 100)
|
|
96
|
+
: 0;
|
|
97
|
+
|
|
98
|
+
const pinStr = pinnedMemoryCount > 0
|
|
99
|
+
? ` ${pinnedMemoryCount} pinned memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} preserved.`
|
|
100
|
+
: '';
|
|
101
|
+
|
|
102
|
+
const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
`[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
|
|
106
|
+
` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
|
|
107
|
+
` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
|
|
108
|
+
(pinStr ? ` ${pinStr.trim()}` : '')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Format a number with thousands separators. */
|
|
113
|
+
function fmtN(n: number): string {
|
|
114
|
+
return n.toLocaleString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the /keep command usage text (shown when no args are provided).
|
|
119
|
+
*
|
|
120
|
+
* Exported for testability — the shell-core handler renders this string directly.
|
|
121
|
+
*/
|
|
122
|
+
export function buildPinUsageText(): string {
|
|
123
|
+
return (
|
|
124
|
+
'[Pin] Usage: /keep <text>\n' +
|
|
125
|
+
'Pinned entries are stored as session memories and included in the compaction handoff as pinned memories.\n' +
|
|
126
|
+
'What pinning guarantees: the text survives the next compaction.\n' +
|
|
127
|
+
'What pinning does NOT guarantee: recovery after process restart (session memories are in-memory only).'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the /keep command success text.
|
|
133
|
+
*
|
|
134
|
+
* @param id - The assigned memory ID (e.g. "mem-1")
|
|
135
|
+
* @param text - The pinned text
|
|
136
|
+
* @param count - Total pinned memory count after adding
|
|
137
|
+
*
|
|
138
|
+
* Exported for testability — the shell-core handler renders this string directly.
|
|
139
|
+
*/
|
|
140
|
+
export function buildPinSuccessText(id: string, text: string, count: number): string {
|
|
141
|
+
return (
|
|
142
|
+
`[Pin] Pinned as ${id}: "${text.slice(0, 60)}${text.length > 60 ? '...' : ''}"\n` +
|
|
143
|
+
` ${count} pinned memor${count === 1 ? 'y' : 'ies'} will survive the next compaction.\n` +
|
|
144
|
+
' Note: session memories are in-memory only and do not persist across restarts.'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context status hint — TASK-056.
|
|
3
|
+
*
|
|
4
|
+
* Produces a short, dismissible status-line hint when the session maintenance
|
|
5
|
+
* level indicates compaction is recommended or repair is needed. The hint is
|
|
6
|
+
* passive and non-blocking: it appears in the footer status row and disappears
|
|
7
|
+
* once the pressure signal clears.
|
|
8
|
+
*
|
|
9
|
+
* Honest wording policy:
|
|
10
|
+
* - suggest-compact → describes the situation and offers /compact
|
|
11
|
+
* - needs-repair → names the failure state honestly without alarming
|
|
12
|
+
* - compacting → shows in-progress text so the user knows something is running
|
|
13
|
+
* - watch → no hint (not yet actionable)
|
|
14
|
+
* - stable/unknown → no hint
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PanelSessionMaintenanceLevel } from '../panels/session-maintenance.ts';
|
|
18
|
+
|
|
19
|
+
export interface ContextStatusHintOptions {
|
|
20
|
+
/** Maintenance level from evaluateSessionMaintenance. */
|
|
21
|
+
readonly level: PanelSessionMaintenanceLevel;
|
|
22
|
+
/** Whether auto-compaction is active (threshold > 0 in config). */
|
|
23
|
+
readonly autoCompactEnabled: boolean;
|
|
24
|
+
/** Current usage percent 0–100. */
|
|
25
|
+
readonly usagePct: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the passive status-line hint text for context pressure.
|
|
30
|
+
*
|
|
31
|
+
* Returns null when no hint is warranted (stable / watch / unknown).
|
|
32
|
+
* The caller renders this as a dim informational line — no prompts, no
|
|
33
|
+
* blocking, no confirmation required.
|
|
34
|
+
*/
|
|
35
|
+
export function buildContextStatusHint(options: ContextStatusHintOptions): string | null {
|
|
36
|
+
const { level, autoCompactEnabled, usagePct } = options;
|
|
37
|
+
|
|
38
|
+
switch (level) {
|
|
39
|
+
case 'needs-repair':
|
|
40
|
+
return ` Context pressure critical (${usagePct}% used) — compaction needs attention. Run /compact or /health review.`;
|
|
41
|
+
|
|
42
|
+
case 'suggest-compact':
|
|
43
|
+
if (autoCompactEnabled) {
|
|
44
|
+
return ` Context high (${usagePct}% used) — auto-compact will run before the next turn.`;
|
|
45
|
+
}
|
|
46
|
+
return ` Context high (${usagePct}% used) — run /compact to recover headroom.`;
|
|
47
|
+
|
|
48
|
+
case 'compacting':
|
|
49
|
+
return ` Compacting context — freeing headroom...`;
|
|
50
|
+
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* architecture cap. No layout logic lives here.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
|
|
8
|
-
import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
|
|
7
|
+
import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
|
|
8
|
+
import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
|
|
9
9
|
import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
|
|
10
10
|
|
|
11
11
|
function maskSecretValue(value: string): string {
|
|
@@ -539,14 +539,25 @@ function rowColorForSetting(modal: SettingsModal, rowText: string): string {
|
|
|
539
539
|
return valueColor(selected);
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
-
function footerText(modal: SettingsModal): string {
|
|
542
|
+
function footerText(modal: SettingsModal, width: number): string {
|
|
543
|
+
// Armed reset gate takes priority over all other footer states.
|
|
544
|
+
if (modal.resetCategoryConfirm !== null || modal.resetAllConfirm !== null)
|
|
545
|
+
return 'Reset armed · Enter/y confirm · Esc/n cancel';
|
|
543
546
|
if (modal.searchFocused) return 'Search · type to filter · Up/Down navigate results · Enter select · Esc exit search';
|
|
544
547
|
if (modal.editingMode) return 'Enter Confirm edit · Esc Cancel edit · text keys edit the selected field';
|
|
545
548
|
if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · / search · Esc close';
|
|
546
549
|
if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · / search · Enter review/sign out · Esc close';
|
|
547
550
|
if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · / search · Enter edit trust · Esc close';
|
|
548
551
|
if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · / search · Enter/Space toggle · Esc close';
|
|
549
|
-
|
|
552
|
+
// Default settings pane: tier the reset affordances by available width.
|
|
553
|
+
// W<80: minimal — only the most critical action survives.
|
|
554
|
+
// W<160: compact but still shows both reset affordances.
|
|
555
|
+
// W≥160: standard with all navigation tokens.
|
|
556
|
+
if (width < 80)
|
|
557
|
+
return 'R reset · Esc';
|
|
558
|
+
if (width < 160)
|
|
559
|
+
return 'Up/Down · Enter/Space edit · ⇧R reset cat · ^⇧R reset all · Esc';
|
|
560
|
+
return 'Focus settings · Up/Down setting · Left · Enter/Space edit/toggle · ⇧R reset cat · ^⇧R reset all · Esc close';
|
|
550
561
|
}
|
|
551
562
|
|
|
552
563
|
export function renderSettingsModal(
|
|
@@ -601,6 +612,6 @@ export function renderSettingsModal(
|
|
|
601
612
|
})),
|
|
602
613
|
contextRows,
|
|
603
614
|
controlRows,
|
|
604
|
-
footer: footerText(modal),
|
|
615
|
+
footer: footerText(modal, width),
|
|
605
616
|
});
|
|
606
617
|
}
|
|
@@ -29,6 +29,11 @@ export interface ShellFooterBuildOptions {
|
|
|
29
29
|
readonly composerStatus?: string;
|
|
30
30
|
readonly composerFlags?: readonly string[];
|
|
31
31
|
readonly composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote';
|
|
32
|
+
/**
|
|
33
|
+
* Passive context pressure hint from buildContextStatusHint.
|
|
34
|
+
* Rendered as a dim informational line above the prompt when non-null.
|
|
35
|
+
*/
|
|
36
|
+
readonly contextStatusHint?: string | null;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface ShellFooterBuildResult {
|
|
@@ -84,5 +89,10 @@ export function buildShellFooter(
|
|
|
84
89
|
);
|
|
85
90
|
const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
|
|
86
91
|
lines.splice(inputBoxRows, 0, ...processIndicator);
|
|
92
|
+
// Passive context status hint — rendered as a dim informational line before the prompt.
|
|
93
|
+
if (options.contextStatusHint) {
|
|
94
|
+
const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
|
|
95
|
+
lines.unshift(hintLine);
|
|
96
|
+
}
|
|
87
97
|
return { lines, height: lines.length };
|
|
88
98
|
}
|
|
@@ -160,6 +160,7 @@ export function createBootstrapCommandActions(
|
|
|
160
160
|
| 'openMcpWorkspace'
|
|
161
161
|
| 'openSecurityPanel'
|
|
162
162
|
| 'openKnowledgePanel'
|
|
163
|
+
| 'openMemoryPanel'
|
|
163
164
|
| 'openRemotePanel'
|
|
164
165
|
| 'openSubscriptionPanel'
|
|
165
166
|
| 'openLocalAuthMaskedEntry'
|
|
@@ -273,6 +274,9 @@ export function createBootstrapCommandActions(
|
|
|
273
274
|
openKnowledgePanel: () => {
|
|
274
275
|
showPanel('knowledge');
|
|
275
276
|
},
|
|
277
|
+
openMemoryPanel: () => {
|
|
278
|
+
showPanel('memory');
|
|
279
|
+
},
|
|
276
280
|
openRemotePanel: () => {
|
|
277
281
|
showPanel('remote');
|
|
278
282
|
},
|
|
@@ -123,6 +123,88 @@ export interface BootstrapCoreState {
|
|
|
123
123
|
|
|
124
124
|
export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
|
|
125
125
|
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Operator narration of inbound channel events
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Narrate an inbound channel event to the operator via the SystemMessageRouter.
|
|
132
|
+
*
|
|
133
|
+
* When an external surface (GitHub, Slack, ntfy, etc.) triggers an agent turn,
|
|
134
|
+
* this function produces a human-readable system message so the operator can
|
|
135
|
+
* observe which event caused the turn. Returns null for internal/companion
|
|
136
|
+
* sources that do not need operator narration.
|
|
137
|
+
*
|
|
138
|
+
* @param event - The normalized inbound event descriptor.
|
|
139
|
+
* @returns A narration string, or null if no narration is appropriate.
|
|
140
|
+
*/
|
|
141
|
+
export function narrateInboundEvent(event: {
|
|
142
|
+
source: string;
|
|
143
|
+
metadata: Readonly<Record<string, unknown>> | undefined;
|
|
144
|
+
}): string | null {
|
|
145
|
+
const { source, metadata } = event;
|
|
146
|
+
if (!source) return null;
|
|
147
|
+
|
|
148
|
+
// Derive the effective surface — prefer metadata.surface, fall back to source.
|
|
149
|
+
const surface = typeof metadata?.surface === 'string' ? metadata.surface : source;
|
|
150
|
+
|
|
151
|
+
// Internal / companion sources do not need operator narration.
|
|
152
|
+
if (surface === 'companion' || source === 'companion') return null;
|
|
153
|
+
if (surface === 'internal' || source === 'internal') return null;
|
|
154
|
+
|
|
155
|
+
// Build a surface label for the log prefix.
|
|
156
|
+
const label = ((): string => {
|
|
157
|
+
switch (surface) {
|
|
158
|
+
case 'github': return '[GitHub]';
|
|
159
|
+
case 'slack': return '[Slack]';
|
|
160
|
+
case 'discord': return '[Discord]';
|
|
161
|
+
case 'ntfy': return '[ntfy]';
|
|
162
|
+
case 'homeassistant': return '[HomeAssistant]';
|
|
163
|
+
case 'telegram': return '[Telegram]';
|
|
164
|
+
case 'google-chat': return '[Google Chat]';
|
|
165
|
+
case 'signal': return '[Signal]';
|
|
166
|
+
case 'whatsapp': return '[WhatsApp]';
|
|
167
|
+
case 'msteams': return '[Teams]';
|
|
168
|
+
case 'imessage': return '[iMessage]';
|
|
169
|
+
case 'bluebubbles': return '[BlueBubbles]';
|
|
170
|
+
case 'mattermost': return '[Mattermost]';
|
|
171
|
+
case 'matrix': return '[Matrix]';
|
|
172
|
+
case 'webhook': return '[Webhook]';
|
|
173
|
+
default: return `[${surface[0]!.toUpperCase()}${surface.slice(1)}]`;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
|
|
177
|
+
const eventType = typeof metadata?.eventType === 'string' ? metadata.eventType : null;
|
|
178
|
+
const eventAction = typeof metadata?.eventAction === 'string' ? metadata.eventAction : null;
|
|
179
|
+
const topic = typeof metadata?.topic === 'string' ? metadata.topic : null;
|
|
180
|
+
const prNumber = typeof metadata?.prNumber === 'number' ? metadata.prNumber : null;
|
|
181
|
+
const issueNumber = typeof metadata?.issueNumber === 'number' ? metadata.issueNumber : null;
|
|
182
|
+
const repo = typeof metadata?.repo === 'string' ? metadata.repo : null;
|
|
183
|
+
|
|
184
|
+
// Build event-specific detail for GitHub events.
|
|
185
|
+
if (surface === 'github' && eventType) {
|
|
186
|
+
const actionPart = eventAction ? ` ${eventAction}` : '';
|
|
187
|
+
let detail = `${eventType}${actionPart} → agent triggered`;
|
|
188
|
+
if (prNumber !== null) {
|
|
189
|
+
detail = `PR #${prNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
|
|
190
|
+
} else if (issueNumber !== null) {
|
|
191
|
+
detail = `Issue #${issueNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
|
|
192
|
+
} else if (repo) {
|
|
193
|
+
detail = `${eventType}${actionPart} in ${repo} → agent triggered`;
|
|
194
|
+
}
|
|
195
|
+
return `${label} ${detail}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ntfy: include topic when available.
|
|
199
|
+
if (surface === 'ntfy' && topic) {
|
|
200
|
+
return `${label} inbound message on topic '${topic}' → agent triggered`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Generic narration for all other surfaces.
|
|
204
|
+
const eventDetail = eventType ? ` ${eventType}${eventAction ? ` ${eventAction}` : ''}` : '';
|
|
205
|
+
return `${label}${eventDetail} inbound event → agent triggered`;
|
|
206
|
+
}
|
|
207
|
+
|
|
126
208
|
export function companionMessageToOrchestratorInputOptions(
|
|
127
209
|
payload: CompanionMessagePayload,
|
|
128
210
|
): OrchestratorUserInputOptions {
|
|
@@ -426,6 +508,30 @@ export async function initializeBootstrapCore(
|
|
|
426
508
|
}
|
|
427
509
|
};
|
|
428
510
|
|
|
511
|
+
// Startup TLS banner — emitted via wrfcBuffer.push() because the
|
|
512
|
+
// SystemMessageRouter is not attached yet at this point in bootstrap. The
|
|
513
|
+
// smart-ref setter on systemMessageRouterRef auto-flushes the buffer when
|
|
514
|
+
// the router attaches, so the message will appear in the WRFC panel on startup.
|
|
515
|
+
{
|
|
516
|
+
const cpEnabled = Boolean(configManager.get('controlPlane.enabled'));
|
|
517
|
+
const cpHostMode = String(configManager.get('controlPlane.hostMode') ?? 'local');
|
|
518
|
+
const cpTlsMode = String(configManager.get('controlPlane.tls.mode') ?? 'off');
|
|
519
|
+
const hlEnabled = Boolean(configManager.get('danger.httpListener'));
|
|
520
|
+
const hlHostMode = String(configManager.get('httpListener.hostMode') ?? 'local');
|
|
521
|
+
const hlTlsMode = String(configManager.get('httpListener.tls.mode') ?? 'off');
|
|
522
|
+
const cpNetworkPlaintext = cpEnabled && cpHostMode !== 'local' && cpTlsMode === 'off';
|
|
523
|
+
const hlNetworkPlaintext = hlEnabled && hlHostMode !== 'local' && hlTlsMode === 'off';
|
|
524
|
+
if (cpNetworkPlaintext || hlNetworkPlaintext) {
|
|
525
|
+
const affected: string[] = [];
|
|
526
|
+
if (cpNetworkPlaintext) affected.push('control plane');
|
|
527
|
+
if (hlNetworkPlaintext) affected.push('HTTP listener');
|
|
528
|
+
wrfcBuffer.push(
|
|
529
|
+
`[SECURITY] TLS is off for the ${affected.join(' and ')} but it is network-reachable. All traffic (credentials, tokens, conversation content) travels in plaintext. Enable TLS (controlPlane.tls.mode / httpListener.tls.mode) or restrict to loopback before exposing to untrusted networks.`,
|
|
530
|
+
'high',
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
429
535
|
runtimeUnsubs.push(
|
|
430
536
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_CONSTRAINTS_ENUMERATED' }>>(
|
|
431
537
|
'WORKFLOW_CONSTRAINTS_ENUMERATED',
|
|
@@ -501,6 +607,16 @@ export async function initializeBootstrapCore(
|
|
|
501
607
|
runtimeUnsubs.push(runtimeBus.on<Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>>(
|
|
502
608
|
'COMPANION_MESSAGE_RECEIVED',
|
|
503
609
|
({ payload }) => {
|
|
610
|
+
// Narrate inbound external events to the operator so they can observe
|
|
611
|
+
// which channel event triggered the agent turn.
|
|
612
|
+
const narration = narrateInboundEvent({
|
|
613
|
+
source: payload.source,
|
|
614
|
+
metadata: payload.metadata,
|
|
615
|
+
});
|
|
616
|
+
if (narration) {
|
|
617
|
+
routeOrBuffer(narration, 'low');
|
|
618
|
+
}
|
|
619
|
+
|
|
504
620
|
if (orchestratorHandleUserInputRef.value) {
|
|
505
621
|
// Delegate to the orchestrator: adds user message + fires a real LLM turn.
|
|
506
622
|
// Preserve surface origin metadata so the SDK can correlate replies back
|