@pellux/goodvibes-agent 1.8.1 → 1.8.2

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 (88) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +1 -1
  3. package/dist/package/{ast-grep-napi.linux-x64-gnu-mkk8xwww.node → ast-grep-napi.linux-x64-gnu-44ar68xs.node} +0 -0
  4. package/dist/package/{ast-grep-napi.linux-x64-musl-ryqtgdv6.node → ast-grep-napi.linux-x64-musl-asvc2tv1.node} +0 -0
  5. package/dist/package/main.js +135337 -118733
  6. package/dist/package/{web-tree-sitter-vmqc5d26.wasm → web-tree-sitter-jbz042ba.wasm} +0 -0
  7. package/docs/tools-and-commands.md +9 -0
  8. package/package.json +3 -2
  9. package/src/agent/capability-registry.ts +300 -0
  10. package/src/agent/email/style-reply-lane.ts +13 -4
  11. package/src/agent/memory-consolidation-receipts.ts +79 -0
  12. package/src/agent/memory-prompt.ts +3 -1
  13. package/src/agent/operator-gateway-call.ts +134 -0
  14. package/src/agent/principal-attribution.ts +97 -0
  15. package/src/agent/prompt-context-receipts.ts +5 -1
  16. package/src/agent/unified-inbox.ts +17 -1
  17. package/src/cli/bundle-command.ts +2 -1
  18. package/src/cli/channel-profiles-command.ts +168 -0
  19. package/src/cli/ci-command.ts +214 -0
  20. package/src/cli/completion.ts +5 -0
  21. package/src/cli/connected-host-metrics.ts +165 -0
  22. package/src/cli/entrypoint.ts +25 -1
  23. package/src/cli/fleet-command.ts +178 -0
  24. package/src/cli/help.ts +115 -0
  25. package/src/cli/import-command.ts +208 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/management-commands.ts +9 -8
  28. package/src/cli/management.ts +42 -0
  29. package/src/cli/memory-command.ts +4 -3
  30. package/src/cli/openclaw-import.ts +382 -0
  31. package/src/cli/operator-command-args.ts +88 -0
  32. package/src/cli/parser.ts +12 -0
  33. package/src/cli/personas-command.ts +3 -2
  34. package/src/cli/principals-command.ts +230 -0
  35. package/src/cli/profiles-command.ts +2 -1
  36. package/src/cli/relay-command.ts +100 -0
  37. package/src/cli/routines-command.ts +4 -3
  38. package/src/cli/skill-bundle-command.ts +3 -2
  39. package/src/cli/skills-command.ts +3 -2
  40. package/src/cli/status.ts +114 -2
  41. package/src/cli/temporal-label.ts +89 -0
  42. package/src/cli/types.ts +7 -0
  43. package/src/cli/workspaces-command.ts +135 -0
  44. package/src/config/agent-settings-policy.ts +8 -0
  45. package/src/config/checkpoint-settings.ts +113 -0
  46. package/src/config/workspace-registration.ts +299 -0
  47. package/src/core/rewind-turn-anchors.ts +82 -0
  48. package/src/input/agent-workspace-categories.ts +1 -0
  49. package/src/input/agent-workspace-channel-triage.ts +27 -8
  50. package/src/input/agent-workspace-local-library-snapshot.ts +5 -1
  51. package/src/input/agent-workspace-onboarding-categories.ts +17 -1
  52. package/src/input/agent-workspace-snapshot.ts +25 -3
  53. package/src/input/agent-workspace-types.ts +5 -5
  54. package/src/input/commands/connected-host-admin-runtime.ts +75 -0
  55. package/src/input/commands.ts +2 -0
  56. package/src/input/settings-modal-types.ts +16 -5
  57. package/src/main.ts +17 -12
  58. package/src/permissions/approval-posture.ts +40 -5
  59. package/src/permissions/prompt.ts +35 -1
  60. package/src/renderer/settings-modal-helpers.ts +11 -0
  61. package/src/renderer/settings-modal.ts +21 -0
  62. package/src/renderer/terminal-escapes.ts +65 -12
  63. package/src/runtime/agent-runtime-events.ts +102 -3
  64. package/src/runtime/bootstrap-core.ts +111 -10
  65. package/src/runtime/bootstrap-external-services.ts +156 -0
  66. package/src/runtime/bootstrap.ts +73 -69
  67. package/src/runtime/context-accounting-source.ts +114 -0
  68. package/src/runtime/conversation-rewind-port.ts +133 -0
  69. package/src/runtime/index.ts +8 -28
  70. package/src/runtime/memory-consolidation-scheduler.ts +67 -0
  71. package/src/runtime/memory-consolidation-wiring.ts +56 -0
  72. package/src/runtime/memory-usage-wiring.ts +75 -0
  73. package/src/runtime/services.ts +250 -14
  74. package/src/runtime/tool-permission-safety.ts +18 -1
  75. package/src/shell/blocking-input.ts +38 -8
  76. package/src/shell/startup-wiring.ts +26 -1
  77. package/src/tools/agent-context-policy.ts +1 -1
  78. package/src/tools/agent-harness-cli-command-policy.ts +29 -3
  79. package/src/tools/agent-harness-learning-auto-promote.ts +7 -3
  80. package/src/tools/agent-harness-memory-posture.ts +57 -2
  81. package/src/tools/agent-harness-metadata.ts +12 -0
  82. package/src/tools/agent-harness-mode-catalog.ts +1 -1
  83. package/src/tools/agent-harness-personal-ops-lanes.ts +8 -6
  84. package/src/tools/agent-harness-prompt-context.ts +3 -1
  85. package/src/tools/agent-harness-tool.ts +3 -2
  86. package/src/tools/agent-policy-explanation.ts +40 -0
  87. package/src/tools/tool-definition-compaction.ts +2 -0
  88. package/src/version.ts +1 -1
@@ -0,0 +1,82 @@
1
+ /**
2
+ * rewind-turn-anchors.ts — the session-scoped join key between the conversation
3
+ * and the workspace-checkpoint store, for message-anchored rewind.
4
+ *
5
+ * The unified rewind service (SDK platform/rewind) anchors a rewind to a
6
+ * `{ sessionId, turnId }`. Files-scope resolves that turnId against the
7
+ * workspace checkpoints the turn engine already stamps with the same turnId.
8
+ * Conversation-scope, however, needs to know HOW MANY conversation messages
9
+ * existed at that turn boundary — a mapping the SDK's checkpoint store does not
10
+ * carry. This registry supplies exactly that mapping: at every TURN_COMPLETED
11
+ * this Agent records the turnId together with the live
12
+ * `conversation.getMessageCount()`, so a later rewind to that turnId can
13
+ * truncate the conversation to precisely the boundary the files checkpoint
14
+ * captured.
15
+ *
16
+ * Ported from goodvibes-tui's src/core/rewind-turn-anchors.ts (same join-key
17
+ * shape, same TURN_COMPLETED wiring point) so the two front-ends' rewind
18
+ * semantics cannot drift.
19
+ *
20
+ * Scope + lifetime: in-memory, keyed by sessionId, populated for the CURRENT
21
+ * process run only. Turns from a resumed prior session (whose turnIds predate
22
+ * this run) are not present — an honest limitation: a rewind to an
23
+ * unrecorded turnId gets exactly the anchors this run recorded, never an
24
+ * invented boundary.
25
+ */
26
+
27
+ /** One recorded turn boundary — the rewind coordinator's per-turn anchor. */
28
+ export interface TurnAnchor {
29
+ /** The turn engine's turn id, shared with the workspace checkpoint's `turnId`. */
30
+ readonly turnId: string;
31
+ /** A short human label (the truncated user prompt) for the recent-turns picker. */
32
+ readonly label: string;
33
+ /** `conversation.getMessageCount()` captured at this turn's completion — the conversation truncation boundary. */
34
+ readonly messageCount: number;
35
+ /** Wall-clock ms at capture, for ordering + age display. */
36
+ readonly at: number;
37
+ }
38
+
39
+ /** Hard cap so a very long session cannot grow this registry without bound. */
40
+ const MAX_ANCHORS_PER_SESSION = 500;
41
+
42
+ const registry = new Map<string, TurnAnchor[]>();
43
+
44
+ /** Trim a user prompt to a single compact line for the picker. */
45
+ export function summarizeTurnLabel(text: string | null | undefined, max = 72): string {
46
+ const oneLine = (text ?? '').replace(/\s+/g, ' ').trim();
47
+ if (oneLine.length === 0) return '(no prompt text)';
48
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
49
+ }
50
+
51
+ /**
52
+ * Record a completed turn's anchor. Idempotent per turnId: a repeated turnId
53
+ * updates the existing entry in place (the checkpoint engine can re-snapshot a
54
+ * turn) rather than duplicating it.
55
+ */
56
+ export function recordTurnAnchor(sessionId: string, anchor: TurnAnchor): void {
57
+ if (!sessionId || !anchor.turnId) return;
58
+ const list = registry.get(sessionId) ?? [];
59
+ const existingIndex = list.findIndex((a) => a.turnId === anchor.turnId);
60
+ if (existingIndex >= 0) {
61
+ list[existingIndex] = anchor;
62
+ } else {
63
+ list.push(anchor);
64
+ if (list.length > MAX_ANCHORS_PER_SESSION) list.splice(0, list.length - MAX_ANCHORS_PER_SESSION);
65
+ }
66
+ registry.set(sessionId, list);
67
+ }
68
+
69
+ /** All recorded anchors for a session, oldest first (chronological). */
70
+ export function getTurnAnchors(sessionId: string): readonly TurnAnchor[] {
71
+ return registry.get(sessionId) ?? [];
72
+ }
73
+
74
+ /** Resolve an anchor by exact turnId, or null when this run never recorded it. */
75
+ export function resolveTurnAnchor(sessionId: string, turnId: string): TurnAnchor | null {
76
+ return registry.get(sessionId)?.find((a) => a.turnId === turnId) ?? null;
77
+ }
78
+
79
+ /** Drop a session's anchors. Exposed for tests and session reset. */
80
+ export function clearTurnAnchors(sessionId: string): void {
81
+ registry.delete(sessionId);
82
+ }
@@ -489,6 +489,7 @@ export const AGENT_WORKSPACE_CATEGORIES: readonly AgentWorkspaceCategory[] = [
489
489
  { id: 'tasks-filter', label: 'Filter host tasks', detail: 'Open a status/kind form for read-only connected-host task filtering.', editorKind: 'task-list-filter', kind: 'editor', safety: 'read-only' },
490
490
  { id: 'task-show', label: 'Inspect host task', detail: 'Open a task-id form for read-only connected-host task metadata.', editorKind: 'task-show', kind: 'editor', safety: 'read-only' },
491
491
  { id: 'task-output', label: 'Show task output', detail: 'Open a task-id form for read-only connected-host task output.', editorKind: 'task-output', kind: 'editor', safety: 'read-only' },
492
+ { id: 'ci-watches-list', label: 'CI watches', detail: 'List standing CI watches on the connected host. Use /ci status <repo> for a per-job conclusion report, and /ci watches create ... --yes to add a watch with channel delivery.', command: '/ci watches list', kind: 'command', safety: 'read-only' },
492
493
  { id: 'approval-review', label: 'Review approval class', detail: 'Open an approval-kind form for read-only review of one approval class.', editorKind: 'approval-review', kind: 'editor', safety: 'read-only' },
493
494
  { id: 'approval-approve', label: 'Approve request', detail: 'Open a confirmed form for approving one pending connected-host approval request by id.', editorKind: 'approval-approve', kind: 'editor', safety: 'safe' },
494
495
  { id: 'approval-deny', label: 'Deny request', detail: 'Open a confirmed form for denying one pending connected-host approval request by id.', editorKind: 'approval-deny', kind: 'editor', safety: 'safe' },
@@ -1,9 +1,16 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { formatAgentChannelDeliveryReceiptLine, readAgentChannelDeliveryReceipts } from '../agent/channel-delivery-receipts.ts';
3
3
  import type { CommandContext } from './command-registry.ts';
4
- import { fetchConnectedHostReadOnlyRoute, type ConnectedHostRouteFailure, type ConnectedHostRouteResult } from './connected-host-routes.ts';
4
+ import {
5
+ fetchConnectedHostReadOnlyRoute,
6
+ resolveConnectedHostConnection,
7
+ type ConnectedHostConnection,
8
+ type ConnectedHostRouteFailure,
9
+ type ConnectedHostRouteResult,
10
+ } from './connected-host-routes.ts';
5
11
  import type { AgentWorkspaceChannelStatus } from './agent-workspace-channels.ts';
6
12
  import { buildAgentWorkspaceChannels } from './agent-workspace-channels.ts';
13
+ import { resolveChannelPrincipalAttribution, UNKNOWN_PRINCIPAL_LABEL } from '../agent/principal-attribution.ts';
7
14
 
8
15
  type JsonRecord = Record<string, unknown>;
9
16
 
@@ -249,15 +256,20 @@ function surfaceMessageTriage(result: ConnectedHostRouteResult, limit: number):
249
256
  };
250
257
  }
251
258
 
252
- function routeBindingView(binding: JsonRecord): Record<string, unknown> {
259
+ async function routeBindingView(binding: JsonRecord, connection: ConnectedHostConnection): Promise<Record<string, unknown>> {
253
260
  const externalId = readString(binding, 'externalId');
261
+ const surfaceKind = readString(binding, 'surfaceKind', 'unknown');
262
+ const attribution = await resolveChannelPrincipalAttribution(connection, surfaceKind, externalId);
254
263
  return {
255
264
  id: readString(binding, 'id', 'binding'),
256
265
  kind: readString(binding, 'kind', 'unknown'),
257
- surfaceKind: readString(binding, 'surfaceKind', 'unknown'),
266
+ surfaceKind,
258
267
  surfaceId: readString(binding, 'surfaceId') || null,
259
268
  title: redactText(readString(binding, 'title'), 100) || null,
260
269
  externalIdDigest: externalId ? `sha256:${digest(externalId)}` : null,
270
+ principal: attribution.principal,
271
+ principalLabel: attribution.label,
272
+ ...(attribution.error ? { principalResolutionError: attribution.error } : {}),
261
273
  sessionPolicy: readString(binding, 'sessionPolicy') || null,
262
274
  threadPolicy: readString(binding, 'threadPolicy') || null,
263
275
  deliveryGuarantee: readString(binding, 'deliveryGuarantee') || null,
@@ -268,7 +280,11 @@ function routeBindingView(binding: JsonRecord): Record<string, unknown> {
268
280
  };
269
281
  }
270
282
 
271
- function routeBindingTriage(result: ConnectedHostRouteResult, limit: number): Record<string, unknown> {
283
+ async function routeBindingTriage(
284
+ result: ConnectedHostRouteResult,
285
+ limit: number,
286
+ connection: ConnectedHostConnection,
287
+ ): Promise<Record<string, unknown>> {
272
288
  if (!result.ok) {
273
289
  return {
274
290
  route: result.route,
@@ -280,12 +296,14 @@ function routeBindingTriage(result: ConnectedHostRouteResult, limit: number): Re
280
296
  }
281
297
  const root = isRecord(result.body) ? result.body : {};
282
298
  const bindings = readRecordArray(root, 'bindings');
299
+ const sliced = bindings.slice(0, limit);
300
+ const bindingViews = await Promise.all(sliced.map((binding) => routeBindingView(binding, connection)));
283
301
  return {
284
302
  route: result.route,
285
303
  state: routeState(result, bindings.length === 0),
286
304
  totalBindings: bindings.length,
287
- bindings: bindings.slice(0, limit).map(routeBindingView),
288
- policy: 'Read-only route continuity. External ids are digested so channel identifiers are not printed into chat.',
305
+ bindings: bindingViews,
306
+ policy: `Read-only route continuity. External ids are digested so channel identifiers are not printed into chat. Sender attribution shows the resolved principal when the channel identity maps to one, and "${UNKNOWN_PRINCIPAL_LABEL}" otherwise.`,
289
307
  };
290
308
  }
291
309
 
@@ -338,6 +356,7 @@ export async function buildAgentWorkspaceChannelTriage(
338
356
  ): Promise<AgentWorkspaceChannelTriage> {
339
357
  const limit = readLimit(args.limit, 12);
340
358
  const channels = buildAgentWorkspaceChannels(context);
359
+ const connection = resolveConnectedHostConnection(context);
341
360
  const [deliveriesResult, messagesResult, bindingsResult] = await Promise.all([
342
361
  fetchConnectedHostReadOnlyRoute(context, '/api/deliveries'),
343
362
  fetchConnectedHostReadOnlyRoute(context, '/api/control-plane/messages'),
@@ -346,7 +365,7 @@ export async function buildAgentWorkspaceChannelTriage(
346
365
  const readiness = channelTriageReadiness(channels, limit);
347
366
  const deliveries = deliveryTriage(deliveriesResult, limit);
348
367
  const surfaceMessages = surfaceMessageTriage(messagesResult, limit);
349
- const routeBindings = routeBindingTriage(bindingsResult, limit);
368
+ const routeBindings = await routeBindingTriage(bindingsResult, limit, connection);
350
369
  const receipts = receiptTriage(context, limit);
351
370
  const routeFailures = routeFailureCount([deliveriesResult, messagesResult, bindingsResult]);
352
371
  const readinessAttention = typeof readiness.attention === 'number' ? readiness.attention : 0;
@@ -457,7 +476,7 @@ export function formatAgentWorkspaceChannelTriage(triage: AgentWorkspaceChannelT
457
476
  lines.push(` state: ${bindings.state}; total: ${bindings.totalBindings}`);
458
477
  const routeRows = Array.isArray(bindings.bindings) ? bindings.bindings.filter(isRecord) : [];
459
478
  for (const binding of routeRows.slice(0, 5)) {
460
- lines.push(` - ${readString(binding, 'id')}: ${readString(binding, 'surfaceKind')} ${readString(binding, 'kind')} external=${readString(binding, 'externalIdDigest')}`);
479
+ lines.push(` - ${readString(binding, 'id')}: ${readString(binding, 'surfaceKind')} ${readString(binding, 'kind')} external=${readString(binding, 'externalIdDigest')} sender=${readString(binding, 'principalLabel', UNKNOWN_PRINCIPAL_LABEL)}`);
461
480
  }
462
481
  if (routeRows.length === 0) lines.push(' no route bindings reported');
463
482
  }
@@ -1,4 +1,4 @@
1
- import type { MemoryRecord } from '@pellux/goodvibes-sdk/platform/state';
1
+ import { memoryRecordTemporalStatus, type MemoryRecord } from '@pellux/goodvibes-sdk/platform/state';
2
2
  import type { AgentNoteRecord } from '../agent/note-registry.ts';
3
3
  import type { AgentPersonaRecord } from '../agent/persona-registry.ts';
4
4
  import { formatAgentRecordOrigin } from '../agent/record-labels.ts';
@@ -125,6 +125,10 @@ export function summarizeMemoryItem(record: MemoryRecord): AgentWorkspaceLocalLi
125
125
  scope: record.scope,
126
126
  cls: record.cls,
127
127
  confidence: record.confidence,
128
+ // Visible expiry: a record outside its [validFrom, validUntil) window is
129
+ // still stored (not deleted) but not prompt-injected — see
130
+ // memoryRecordTemporalStatus / describeMemoryPromptEligibility.
131
+ temporalStatus: memoryRecordTemporalStatus(record),
128
132
  };
129
133
  }
130
134
 
@@ -1,7 +1,8 @@
1
1
  import type { AgentWorkspaceCategory } from './agent-workspace-types.ts';
2
2
  import { settingAction } from './agent-workspace-category-actions.ts';
3
+ import { renderCapabilityReadinessLine } from '../agent/capability-registry.ts';
3
4
 
4
- export const AGENT_WORKSPACE_ONBOARDING_DETAIL_CATEGORIES: readonly AgentWorkspaceCategory[] = [
5
+ const ONBOARDING_DETAIL_CATEGORIES_BASE: readonly AgentWorkspaceCategory[] = [
5
6
  {
6
7
  id: 'onboarding-channels',
7
8
  group: 'ONBOARDING',
@@ -80,6 +81,8 @@ export const AGENT_WORKSPACE_ONBOARDING_DETAIL_CATEGORIES: readonly AgentWorkspa
80
81
  { id: 'notification-remove-webhook', label: 'Remove notification target', detail: 'Remove one webhook notification target after confirmation.', editorKind: 'notify-webhook-remove', kind: 'editor', safety: 'safe' },
81
82
  { id: 'notification-clear-webhooks', label: 'Clear notification targets', detail: 'Remove every configured webhook notification target after confirmation.', editorKind: 'notify-webhook-clear', kind: 'editor', safety: 'safe' },
82
83
  { id: 'notification-test-webhooks', label: 'Test notification targets', detail: 'Send one test notification to configured targets after typed confirmation.', editorKind: 'notify-webhook-test', kind: 'editor', safety: 'safe' },
84
+ { id: 'principals-list', label: 'People & identities', detail: 'List the cross-channel principal registry: who each channel identity (Slack member, Telegram chat, ...) resolves to. Unmapped senders show as "unknown principal" in session attribution; use /principals create ... --yes to map them.', command: '/principals list', kind: 'command', safety: 'read-only' },
85
+ { id: 'channel-profiles-list', label: 'Channel session profiles', detail: 'List per-channel session profile bindings: the model, provider, and permission mode sessions from each channel start with. Use /channel-profiles set <surface-kind> ... --yes to change one.', command: '/channel-profiles list', kind: 'command', safety: 'read-only' },
83
86
  { id: 'channel-safety', label: 'Delivery safety', detail: 'Messages only go out when you explicitly send them. Nothing is delivered silently from this page.', kind: 'guidance', safety: 'blocked' },
84
87
  ],
85
88
  },
@@ -142,3 +145,16 @@ export const AGENT_WORKSPACE_ONBOARDING_DETAIL_CATEGORIES: readonly AgentWorkspa
142
145
  ],
143
146
  },
144
147
  ];
148
+
149
+ /**
150
+ * Each onboarding category shows the readiness of the capabilities it
151
+ * advertises, appended to its detail and sourced entirely from the capability
152
+ * registry. No category hand-writes a maturity claim; the registry is the one
153
+ * source of truth, so onboarding copy and the enforcement tests can never
154
+ * drift from a capability's declared level.
155
+ */
156
+ export const AGENT_WORKSPACE_ONBOARDING_DETAIL_CATEGORIES: readonly AgentWorkspaceCategory[] =
157
+ ONBOARDING_DETAIL_CATEGORIES_BASE.map((category) => {
158
+ const readinessLine = renderCapabilityReadinessLine(category.id);
159
+ return readinessLine ? { ...category, detail: `${category.detail} ${readinessLine}` } : category;
160
+ });
@@ -1,6 +1,7 @@
1
1
  import { basename, sep } from 'node:path';
2
2
  import type { CommandContext } from './command-registry.ts';
3
3
  import { AgentRoutineRegistry } from '../agent/routine-registry.ts';
4
+ import { memoryRecordTemporalStatus } from '@pellux/goodvibes-sdk/platform/state';
4
5
  import { describeMemoryPromptEligibility, isPromptActiveMemory } from '../agent/memory-prompt.ts';
5
6
  import { summarizeAgentBehaviorDiscovery } from '../agent/behavior-discovery-summary.ts';
6
7
  import { buildSetupWizardDurableReceipts } from '../agent/setup-wizard-artifact-receipts.ts';
@@ -56,6 +57,10 @@ export interface AgentWorkspaceLiveMemoryCounters {
56
57
  readonly count: number;
57
58
  readonly reviewQueueCount: number;
58
59
  readonly promptActiveCount: number;
60
+ /** Stored but not yet valid (now < validFrom). Still counted in `count`. */
61
+ readonly temporallyPendingCount: number;
62
+ /** Stored but past validUntil. Still counted in `count`, never deleted. */
63
+ readonly temporallyExpiredCount: number;
59
64
  readonly items: readonly AgentWorkspaceLocalLibraryItem[];
60
65
  }
61
66
 
@@ -78,12 +83,27 @@ export interface AgentWorkspaceLiveMemoryCounters {
78
83
  */
79
84
  export function readLiveAgentMemoryCounters(context: CommandContext): AgentWorkspaceLiveMemoryCounters {
80
85
  const memory = context.clients?.agentKnowledgeApi?.memory;
81
- if (!memory) return { count: 0, reviewQueueCount: 0, promptActiveCount: 0, items: [] };
86
+ if (!memory) {
87
+ return { count: 0, reviewQueueCount: 0, promptActiveCount: 0, temporallyPendingCount: 0, temporallyExpiredCount: 0, items: [] };
88
+ }
82
89
  const records = [...memory.getAll()].sort((left, right) => right.updatedAt - left.updatedAt);
90
+ let temporallyPendingCount = 0;
91
+ let temporallyExpiredCount = 0;
92
+ for (const record of records) {
93
+ const temporalStatus = memoryRecordTemporalStatus(record);
94
+ if (temporalStatus === 'pending') temporallyPendingCount += 1;
95
+ else if (temporalStatus === 'expired') temporallyExpiredCount += 1;
96
+ }
83
97
  return {
84
98
  count: records.length,
85
99
  reviewQueueCount: memory.reviewQueue(100).length,
86
- promptActiveCount: records.filter(isPromptActiveMemory).length,
100
+ // Bound to one arg — a bare `.filter(isPromptActiveMemory)` leaks the
101
+ // array index in as Array.filter's second (index) argument, which lands
102
+ // in isPromptActiveMemory's `now` param and silently breaks the
103
+ // temporal-validity check for every record past index 0.
104
+ promptActiveCount: records.filter((record) => isPromptActiveMemory(record)).length,
105
+ temporallyPendingCount,
106
+ temporallyExpiredCount,
87
107
  // Each item carries the honest, per-record eligibility reason straight from
88
108
  // describeMemoryPromptEligibility — the same wording source
89
109
  // prompt-context-receipts.ts and agent-harness-prompt-context.ts use for prompt
@@ -104,7 +124,7 @@ export function buildAgentWorkspaceMemorySnapshot(context: CommandContext): Agen
104
124
  try {
105
125
  return readLiveAgentMemoryCounters(context);
106
126
  } catch {
107
- return { count: 0, reviewQueueCount: 0, promptActiveCount: 0, items: [] };
127
+ return { count: 0, reviewQueueCount: 0, promptActiveCount: 0, temporallyPendingCount: 0, temporallyExpiredCount: 0, items: [] };
108
128
  }
109
129
  }
110
130
 
@@ -326,6 +346,8 @@ export function buildAgentWorkspaceRuntimeSnapshot(context: CommandContext): Age
326
346
  localMemoryCount: memorySnapshot.count,
327
347
  localMemoryReviewQueueCount: memorySnapshot.reviewQueueCount,
328
348
  localMemoryPromptActiveCount: memorySnapshot.promptActiveCount,
349
+ localMemoryTemporallyPendingCount: memorySnapshot.temporallyPendingCount,
350
+ localMemoryTemporallyExpiredCount: memorySnapshot.temporallyExpiredCount,
329
351
  localMemories: memorySnapshot.items,
330
352
  localNoteCount: noteSnapshot.count,
331
353
  localNoteReviewQueueCount: noteSnapshot.reviewQueueCount,
@@ -370,13 +370,11 @@ export interface AgentWorkspaceLocalLibraryItem {
370
370
  readonly missingRequirementCount?: number;
371
371
  readonly missingRequirements?: readonly string[];
372
372
  readonly startCount?: number;
373
- /** Memory-only: whether this record currently clears the prompt-injection
374
- * recall floor, per describeMemoryPromptEligibility. */
373
+ /** Memory-only: whether this record currently clears the prompt-injection recall floor, per describeMemoryPromptEligibility. */
375
374
  readonly promptEligible?: boolean;
376
- /** Memory-only: the honest, per-record reason from describeMemoryPromptEligibility —
377
- * the same wording source prompt-context-receipts.ts and agent-harness-prompt-context.ts
378
- * use, so this workspace snapshot never has to invent its own paraphrase. */
375
+ /** Memory-only: the honest, per-record reason from describeMemoryPromptEligibility — the same wording source prompt-context-receipts.ts and agent-harness-prompt-context.ts use. */
379
376
  readonly promptEligibilityReason?: string;
377
+ readonly temporalStatus?: 'active' | 'pending' | 'expired'; // memoryRecordTemporalStatus; pending/expired stay stored, just not prompt-injected
380
378
  }
381
379
 
382
380
  export interface AgentWorkspaceRecentReviewerHandoffArtifact {
@@ -699,6 +697,8 @@ export interface AgentWorkspaceRuntimeSnapshot {
699
697
  readonly localMemoryCount: number;
700
698
  readonly localMemoryReviewQueueCount: number;
701
699
  readonly localMemoryPromptActiveCount: number;
700
+ readonly localMemoryTemporallyPendingCount: number;
701
+ readonly localMemoryTemporallyExpiredCount: number;
702
702
  readonly localMemories: readonly AgentWorkspaceLocalLibraryItem[];
703
703
  readonly localNoteCount: number;
704
704
  readonly localNoteReviewQueueCount: number;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * TUI slash commands for the connected host's CI-watch, principal registry,
3
+ * and per-channel profile admin families: /ci, /principals, /channel-profiles.
4
+ *
5
+ * These are thin bridges onto the exact same CLI handlers the terminal
6
+ * commands use (src/cli/ci-command.ts, principals-command.ts,
7
+ * channel-profiles-command.ts): the TUI handler re-parses the invocation
8
+ * through the real CLI parser and hands the result to the CLI handler, so
9
+ * subcommand grammar, --yes confirmation gates, per-job CI conclusion
10
+ * rendering, and connected-host failure classification can never drift
11
+ * between the two surfaces. Output prints into the transcript verbatim.
12
+ */
13
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
14
+ import { parseGoodVibesCli } from '../../cli/parser.ts';
15
+ import type { CliCommandOutput } from '../../cli/types.ts';
16
+ import type { CliCommandRuntime } from '../../cli/management.ts';
17
+ import { handleCiCommand } from '../../cli/ci-command.ts';
18
+ import { handlePrincipalsCommand } from '../../cli/principals-command.ts';
19
+ import { handleChannelProfilesCommand } from '../../cli/channel-profiles-command.ts';
20
+ import { requireShellPaths } from './runtime-services.ts';
21
+
22
+ function buildCliRuntime(ctx: CommandContext, command: string, args: readonly string[]): CliCommandRuntime {
23
+ const shellPaths = requireShellPaths(ctx);
24
+ return {
25
+ cli: parseGoodVibesCli([command, ...args]),
26
+ configManager: ctx.platform.configManager,
27
+ workingDirectory: shellPaths.workingDirectory,
28
+ homeDirectory: shellPaths.homeDirectory,
29
+ };
30
+ }
31
+
32
+ async function runBridgedCliCommand(
33
+ ctx: CommandContext,
34
+ command: string,
35
+ args: readonly string[],
36
+ handler: (runtime: CliCommandRuntime) => Promise<CliCommandOutput>,
37
+ ): Promise<void> {
38
+ const result = await handler(buildCliRuntime(ctx, command, args));
39
+ ctx.print(result.output);
40
+ }
41
+
42
+ export function registerConnectedHostAdminCommands(registry: CommandRegistry): void {
43
+ registry.register({
44
+ name: 'ci',
45
+ description: 'Check a repo/PR CI status with per-job conclusions and manage standing CI watches on the connected host',
46
+ hidden: true,
47
+ usage: 'status <repo> [--ref <ref>] [--pr <number>] | watches list | watches create <repo> --delivery-channel <channel> [--trigger-fix-session] --yes | watches run <watch-id> | watches delete <watch-id> --yes',
48
+ argsHint: 'status <repo> | watches list',
49
+ async handler(args, ctx) {
50
+ await runBridgedCliCommand(ctx, 'ci', args, handleCiCommand);
51
+ },
52
+ });
53
+ registry.register({
54
+ name: 'principals',
55
+ aliases: ['principal'],
56
+ description: 'Manage the connected host cross-channel principal identity registry',
57
+ hidden: true,
58
+ usage: 'list | get <id> | resolve --channel <channel> --value <value> | create --name <name> --kind <user|bot|service|token> [--identity channel:value] --yes | update <id> [...] --yes | delete <id> --yes',
59
+ argsHint: 'list | resolve --channel slack --value U123',
60
+ async handler(args, ctx) {
61
+ await runBridgedCliCommand(ctx, 'principals', args, handlePrincipalsCommand);
62
+ },
63
+ });
64
+ registry.register({
65
+ name: 'channel-profiles',
66
+ aliases: ['channel-profile'],
67
+ description: 'Manage per-channel session profile bindings (model, provider, permission mode) on the connected host',
68
+ hidden: true,
69
+ usage: 'list | get <surface-kind> [--channel-id <id>] | set <surface-kind> [--model <model>] [--provider <provider>] [--permission-mode <mode>] --yes | delete <surface-kind> --yes',
70
+ argsHint: 'list | get slack',
71
+ async handler(args, ctx) {
72
+ await runBridgedCliCommand(ctx, 'channel-profiles', args, handleChannelProfilesCommand);
73
+ },
74
+ });
75
+ }
@@ -38,6 +38,7 @@ import { registerBriefRuntimeCommands } from './commands/brief-runtime.ts';
38
38
  import { registerSupportBundleRuntimeCommands } from './commands/support-bundle-runtime.ts';
39
39
  import { registerCompatRuntimeCommands } from './commands/compat-runtime.ts';
40
40
  import { registerOperatorActionRuntimeCommands } from './commands/operator-actions-runtime.ts';
41
+ import { registerConnectedHostAdminCommands } from './commands/connected-host-admin-runtime.ts';
41
42
  import { registerEmailRuntimeCommands } from './commands/email-runtime.ts';
42
43
  import { registerCalendarRuntimeCommands } from './commands/calendar-runtime.ts';
43
44
 
@@ -65,6 +66,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
65
66
  registerSupportBundleRuntimeCommands(registry);
66
67
  registerCompatRuntimeCommands(registry);
67
68
  registerOperatorActionRuntimeCommands(registry);
69
+ registerConnectedHostAdminCommands(registry);
68
70
  registerCalendarRuntimeCommands(registry);
69
71
  registerAgentRuntimeProfileRuntimeCommands(registry);
70
72
  registerVibeRuntimeCommands(registry);
@@ -27,16 +27,19 @@ export type SettingsCategory =
27
27
  | 'behavior'
28
28
  | 'storage'
29
29
  | 'permissions'
30
+ | 'diagnostics'
30
31
  | 'tools'
31
32
  | 'helper'
32
33
  | 'tts'
33
34
  | 'surfaces'
34
35
  | 'automation'
36
+ | 'checkin'
35
37
  | 'service'
36
38
  | 'controlPlane'
37
39
  | 'httpListener'
38
40
  | 'web'
39
41
  | 'watchers'
42
+ | 'relay'
40
43
  | 'network'
41
44
  | 'orchestration'
42
45
  | 'planner'
@@ -50,7 +53,15 @@ export type SettingsCategory =
50
53
  | 'mcp'
51
54
  | 'flags'
52
55
  | 'release'
53
- | 'daemon';
56
+ | 'daemon'
57
+ | 'atRest'
58
+ | 'learning'
59
+ | 'agents'
60
+ | 'notifications'
61
+ | 'policy'
62
+ | 'fetch'
63
+ | 'security'
64
+ | 'integrations';
54
65
 
55
66
  export type SettingsFocusPane = 'categories' | 'settings';
56
67
 
@@ -58,11 +69,11 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
58
69
  readonly label: string;
59
70
  readonly categories: readonly SettingsCategory[];
60
71
  }> = [
61
- { label: 'Agent Experience', categories: ['display', 'ui', 'behavior', 'permissions'] },
72
+ { label: 'Agent Experience', categories: ['display', 'ui', 'behavior', 'agents', 'notifications', 'permissions', 'policy', 'fetch', 'diagnostics'] },
62
73
  { label: 'Models and Providers', categories: ['provider', 'subscriptions', 'helper', 'tools', 'tts'] },
63
- { label: 'Agent-local state', categories: ['storage', 'cache', 'telemetry'] },
64
- { label: 'Channels and Tools', categories: ['surfaces', 'mcp', 'automation'] },
65
- { label: 'Daemon Runtime', categories: ['daemon', 'service', 'controlPlane', 'httpListener', 'web', 'watchers', 'network'] },
74
+ { label: 'Agent-local state', categories: ['storage', 'cache', 'telemetry', 'atRest', 'security', 'learning'] },
75
+ { label: 'Channels and Tools', categories: ['surfaces', 'mcp', 'automation', 'checkin', 'integrations'] },
76
+ { label: 'Daemon Runtime', categories: ['daemon', 'service', 'controlPlane', 'httpListener', 'web', 'watchers', 'network', 'relay'] },
66
77
  { label: 'Advanced Runtime', categories: ['orchestration', 'planner', 'runtime', 'sandbox', 'batch', 'cloudflare', 'wrfc'] },
67
78
  { label: 'Advanced', categories: ['flags', 'release'] },
68
79
  ];
package/src/main.ts CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  loadRecoveryConversation,
42
42
  } from '@/runtime/index.ts';
43
43
  import type { SessionSnapshot } from '@/runtime/index.ts';
44
- import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
44
+ import { handleBlockingShellInput, type PendingPermissionState, type PendingWorkspaceRegistrationState } from './shell/blocking-input.ts';
45
45
  import { createAgentWorkspaceFullscreenComposite, createFullscreenCompositeFromLines } from './shell/agent-workspace-fullscreen.ts';
46
46
  import { getTerminalSize } from './shell/terminal-size.ts';
47
47
  import { buildShellSessionContinuityHints } from './shell/session-continuity-hints.ts';
@@ -49,7 +49,8 @@ import { wireShellUiOpeners } from './shell/ui-openers.ts';
49
49
  import { deriveComposerState } from './core/composer-state.ts';
50
50
  import { buildPersistedSessionContext } from '@/runtime/index.ts';
51
51
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
52
- import { FOCUS_ENABLE, FOCUS_DISABLE, installFocusModeExitGuard, markFocusModeEnabled, wrapRequestPermissionWithApprovalAlert } from './shell/terminal-focus-mode.ts';
52
+ import { installFocusModeExitGuard, markFocusModeEnabled, wrapRequestPermissionWithApprovalAlert } from './shell/terminal-focus-mode.ts';
53
+ import { CLEAR_VIEWPORT_HOME, buildEnterSequence, buildExitSequence } from './renderer/terminal-escapes.ts';
53
54
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
54
55
  import { applyInitialTuiCliState, formatFatalStartupErrorForLog, formatFatalStartupErrorForUser, getInteractiveTerminalLaunchError } from './cli/tui-startup.ts';
55
56
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
@@ -62,8 +63,7 @@ import { createAutonomySurfacing, buildCalendarEventsLister, buildSkillDraftProp
62
63
  import { startHardwareProbe } from './core/hardware-profile.ts';
63
64
  import { readApprovalPostureFromConfig } from './permissions/approval-posture.ts';
64
65
 
65
- const ALT_SCREEN_ENTER = '\x1b[?1049h', ALT_SCREEN_EXIT = '\x1b[?1049l', MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h', MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l', CURSOR_HIDE = '\x1b[?25l', CURSOR_SHOW = '\x1b[?25h', CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
66
- const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u', KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l', PASTE_ENABLE = '\x1b[?2004h', PASTE_DISABLE = '\x1b[?2004l';
66
+ // Escape bytes and enter/exit sequencing live in renderer/terminal-escapes.ts (re-exported from @pellux/goodvibes-terminal-shell) so this file never holds its own drifting copy.
67
67
 
68
68
  async function main() {
69
69
  const stdout = process.stdout;
@@ -223,7 +223,9 @@ async function main() {
223
223
  const unsubs: Array<() => void> = [];
224
224
  let recoveryInterval: ReturnType<typeof setInterval> | null = null;
225
225
  let stopSpokenOutputForExit: (() => Promise<void>) | null = null;
226
- let recoveryPending = false;
226
+ let recoveryPending = false, pendingWorkspaceRegistration: PendingWorkspaceRegistrationState | null = null;
227
+ // Set by exitApp before the terminal-restore write; render() checks this so no late frame can paint over the screen after the terminal has been handed back.
228
+ let terminalRestored = false;
227
229
 
228
230
  const sigintHandler = (): void => input.feed('\x03');
229
231
  const unhandledRejectionHandler = createUnhandledRejectionHandler({
@@ -242,6 +244,8 @@ async function main() {
242
244
  // spoken-audio drain below must not re-run teardown.
243
245
  if (exiting) return;
244
246
  exiting = true;
247
+ // Gate render() before anything else so no late frame follows the terminal-restore write below.
248
+ terminalRestored = true;
245
249
  // Exit lets the spoken audio the user is already hearing finish inside a
246
250
  // short bounded window (capped inside stopForExit) instead of killing the
247
251
  // player mid-drain; queued-but-unplayed speech is dropped. Deliberate
@@ -263,8 +267,7 @@ async function main() {
263
267
  stdout.removeListener('resize', resizeHandler);
264
268
  process.removeListener('SIGINT', sigintHandler);
265
269
  process.removeListener('unhandledRejection', unhandledRejectionHandler);
266
- const exitScreen = cli.flags.noAltScreen ? CLEAR_SCREEN : CLEAR_SCREEN + ALT_SCREEN_EXIT;
267
- allowTerminalWrite(() => stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + FOCUS_DISABLE + CURSOR_SHOW + exitScreen));
270
+ allowTerminalWrite(() => stdout.write(buildExitSequence(cli.flags.noAltScreen)));
268
271
  terminalOutputGuard.dispose();
269
272
  stdin.setRawMode(false);
270
273
  // The terminal is already restored above; only the process exit waits for
@@ -388,7 +391,7 @@ async function main() {
388
391
  commandContext.scrollToLine = scrollToLine;
389
392
  commandContext.clearScreen = () => {
390
393
  compositor.resetDiff();
391
- allowTerminalWrite(() => stdout.write(CLEAR_SCREEN));
394
+ allowTerminalWrite(() => stdout.write(CLEAR_VIEWPORT_HOME));
392
395
  render();
393
396
  };
394
397
  commandContext.toggleActivitySidebar = () => {
@@ -472,6 +475,8 @@ async function main() {
472
475
  };
473
476
 
474
477
  const render = () => {
478
+ // The terminal has already been handed back to the shell; never paint another frame over it.
479
+ if (terminalRestored) return;
475
480
  const { width, height } = getTerminalSize(stdout);
476
481
 
477
482
  // Fire-and-forget refresh for the 'Coming up' sidebar section.
@@ -685,7 +690,7 @@ async function main() {
685
690
  stdin.setRawMode(true);
686
691
  stdin.resume();
687
692
  stdin.setEncoding('utf8');
688
- allowTerminalWrite(() => { markFocusModeEnabled(); return stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE + FOCUS_ENABLE); });
693
+ allowTerminalWrite(() => { markFocusModeEnabled(); return stdout.write(buildEnterSequence(cli.flags.noAltScreen)); });
689
694
 
690
695
  // Forced dark/light before first paint; auto (TTY) probes + repaints once if light.
691
696
  const themeProbe = installStartupThemeProbe({
@@ -710,6 +715,7 @@ async function main() {
710
715
  data,
711
716
  pendingPermission,
712
717
  recoveryPending,
718
+ pendingWorkspaceRegistration,
713
719
  abortTurn: () => orchestrator.abort(),
714
720
  conversation,
715
721
  systemMessageRouter,
@@ -717,8 +723,7 @@ async function main() {
717
723
  loadRecoveryConversation: () => loadRecoveryConversation({ homeDirectory }),
718
724
  deleteRecoveryFile: () => deleteRecoveryFile({ homeDirectory }),
719
725
  });
720
- pendingPermission = blocking.pendingPermission;
721
- recoveryPending = blocking.recoveryPending;
726
+ ({ pendingPermission, recoveryPending, pendingWorkspaceRegistration } = blocking);
722
727
  if (blocking.handled) {
723
728
  return;
724
729
  }
@@ -760,7 +765,7 @@ async function main() {
760
765
 
761
766
  // Wire streaming-speed metrics, auto-save, and recovery — all run after the
762
767
  // first render so they land as ambient context, never startup blockers.
763
- ({ recoveryInterval, recoveryPending } = wireSessionPersistenceAndRecovery({
768
+ ({ recoveryInterval, recoveryPending, pendingWorkspaceRegistration } = wireSessionPersistenceAndRecovery({
764
769
  buildCurrentSessionSnapshot,
765
770
  runtime,
766
771
  conversation,