@pellux/goodvibes-tui 1.21.0 → 1.22.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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +714 -177
  4. package/docs/foundation-artifacts/peer-contract.json +8 -4
  5. package/package.json +9 -7
  6. package/src/cli/entrypoint.ts +6 -0
  7. package/src/cli/service-posture.ts +6 -2
  8. package/src/config/run-daemon-config-migration.ts +46 -0
  9. package/src/core/conversation-compaction-render.ts +88 -0
  10. package/src/core/conversation-line-cache.ts +120 -92
  11. package/src/core/conversation-rendering.ts +290 -233
  12. package/src/core/conversation-turn-structure.ts +464 -0
  13. package/src/core/conversation-types.ts +8 -9
  14. package/src/daemon/cli.ts +71 -5
  15. package/src/input/commands/local-runtime.ts +14 -14
  16. package/src/input/commands/operator-rpc.ts +15 -4
  17. package/src/input/daemon-owned-settings-descriptions.ts +53 -0
  18. package/src/input/handler-content-actions.ts +4 -2
  19. package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
  20. package/src/input/onboarding/onboarding-feature-units.ts +38 -0
  21. package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
  22. package/src/input/search.ts +1 -1
  23. package/src/input/settings-modal-data.ts +15 -85
  24. package/src/input/settings-modal-search.ts +99 -0
  25. package/src/input/settings-modal-types.ts +3 -1
  26. package/src/renderer/conversation-surface.ts +37 -11
  27. package/src/renderer/conversation-tree.ts +179 -0
  28. package/src/renderer/settings-modal-helpers.ts +56 -2
  29. package/src/renderer/settings-modal.ts +9 -50
  30. package/src/renderer/tool-call.ts +38 -5
  31. package/src/runtime/cloudflare-control-plane.ts +4 -1
  32. package/src/runtime/feature-settings.ts +7 -0
  33. package/src/runtime/index.ts +22 -1
  34. package/src/runtime/legacy-daemon-migration.ts +2 -0
  35. package/src/verification/verification-ledger.ts +115 -2
  36. package/src/version.ts +1 -1
  37. package/src/core/conversation-tool-groups.ts +0 -152
@@ -0,0 +1,464 @@
1
+ /**
2
+ * conversation-turn-structure.ts — turns the flat message array into the
3
+ * structural render plan the transcript tree draws from.
4
+ *
5
+ * Three jobs, deliberately not conflated:
6
+ *
7
+ * 1. **Turn grouping.** Which assistant messages share one `● assistant`
8
+ * header (see computeAssistantTurns).
9
+ *
10
+ * 2. **Structural placement.** Where each row goes. A tool result is a child
11
+ * of the CALL that produced it, identified by callId — not a row appended
12
+ * wherever it happens to land in the message array. Two calls issued
13
+ * together run concurrently and settle in whatever order they finish; the
14
+ * later-finishing call's result must still render inside its own call's
15
+ * subtree, which is above any call issued afterwards. This is why the plan
16
+ * is a permutation of the message array rather than a walk of it.
17
+ *
18
+ * 3. **Connector geometry.** Each branch row's box-drawing connector and the
19
+ * `│` gutters of its still-open ancestors.
20
+ *
21
+ * ## Node identity
22
+ *
23
+ * Every planned row carries a stable string id — `m:<absIdx>` for a message,
24
+ * `c:<absIdx>:<callIndex>` for one tool call, each prefixed by its scope (see
25
+ * nesting below). Ids derive from append-only indices that are never
26
+ * renumbered, so a row inserted in the middle does not change any other row's
27
+ * id. The line cache keys on these ids (not on buffer position or plan order),
28
+ * so an insertion above a row reuses that row's cached lines.
29
+ *
30
+ * ## Nesting is a render-layer concern, not a transcript-model one
31
+ *
32
+ * `ConversationMessageSnapshot` is flat and stays flat. Deeper nesting needs
33
+ * no schema change: a subagent keeps its own ConversationManager, and
34
+ * `AgentManager.getConversationSnapshot(agentId)` already exposes it live (the
35
+ * same read `fleet-panel.ts` uses to draw an attached agent's transcript). So
36
+ * when a tool call spawned an agent, the plan SPLICES that agent's own plan in
37
+ * beneath the call, recursively — the identical operation to lifting a result
38
+ * into its call's subtree, applied to a nested snapshot. The parent
39
+ * transcript, the persisted format, and the message schema are untouched; only
40
+ * the render plan nests.
41
+ *
42
+ * Because child snapshots are read at plan time on every rebuild, a child's
43
+ * rows appear as they happen rather than when the child finishes, and a
44
+ * child's late row inserts into its own subtree.
45
+ *
46
+ * Depth is unbounded in principle and bounded in practice by MAX_NEST_DEPTH
47
+ * plus a per-path cycle guard, because a spawn graph that revisits an agent id
48
+ * would otherwise recurse forever. Both guards report themselves on the node
49
+ * (`truncated`) rather than silently dropping rows.
50
+ *
51
+ * ## Connector vocabulary
52
+ *
53
+ * Proper box drawing: `├` for a branch with siblings below it, `└` for the
54
+ * last sibling, `│` carried down the gutter for every ancestor whose subtree
55
+ * is still open. Connectors are a PURE FUNCTION of current structure —
56
+ * recomputed from scratch on every plan build, never cached — so a row that
57
+ * stops being last flips `└`→`├` on the next rebuild.
58
+ *
59
+ * That flip is one cell. Indentation width, content column, and row text are
60
+ * all independent of sibling status by construction: `indentCols` depends only
61
+ * on depth and width, and the connector occupies a column that exists whether
62
+ * it holds `├` or `└`. So a sibling arriving repaints a connector cell and
63
+ * moves no text. (The rejected alternative — a uniform glyph — cannot express
64
+ * "this subtree continues past here", which is what makes a deep tree
65
+ * scannable.)
66
+ */
67
+
68
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
69
+
70
+ type Message = ConversationMessageSnapshot;
71
+
72
+ /**
73
+ * Hard ceiling on nesting depth. A spawn chain deeper than this renders its
74
+ * ancestors and marks the deepest shown call as truncated rather than
75
+ * recursing without bound.
76
+ */
77
+ export const MAX_NEST_DEPTH = 8;
78
+
79
+ /** Connector drawn at a branch row's own indent column. */
80
+ export type TreeConnector = '├' | '└';
81
+
82
+ export interface RenderNode {
83
+ /** Stable identity — survives insertions above it. Scope-prefixed. */
84
+ readonly id: string;
85
+ readonly kind: 'message' | 'toolcall';
86
+ /** Index of the message within ITS OWN snapshot array. */
87
+ readonly absIdx: number;
88
+ /** Position within the owning assistant message's toolCalls — 'toolcall' only. */
89
+ readonly callIndex?: number;
90
+ /** 0 = flush; >=1 = branch depth. */
91
+ readonly depth: number;
92
+ /** The resolved message this row renders (root or nested snapshot). */
93
+ readonly message: Message;
94
+ /**
95
+ * Scope prefix identifying which snapshot `absIdx` indexes into. Empty for
96
+ * the root transcript, so root collapse keys stay byte-identical to what
97
+ * they were before nesting existed.
98
+ */
99
+ readonly scope: string;
100
+ /** Agent whose snapshot this row came from, when nested. */
101
+ readonly agentId?: string;
102
+ /** Connector for this row; undefined at depth 0. */
103
+ readonly connector?: TreeConnector;
104
+ /**
105
+ * Ancestor depths (1-based) whose subtree continues below this row, so the
106
+ * renderer draws `│` in those gutters. Excludes this row's own depth.
107
+ */
108
+ readonly openAncestorDepths: readonly number[];
109
+ /** Set when recursion stopped here (depth ceiling or cycle) — surfaced honestly. */
110
+ readonly truncated?: 'depth' | 'cycle';
111
+ }
112
+
113
+ /**
114
+ * Which assistant messages share one header, and what that header must say.
115
+ * Keyed by the absolute index of every assistant message in the run.
116
+ */
117
+ export interface AssistantTurnMembership {
118
+ readonly turnKey: string;
119
+ readonly headIdx: number;
120
+ readonly isHead: boolean;
121
+ readonly toolCallCount: number;
122
+ readonly sharedToolLabel: string | undefined;
123
+ readonly hasReasoning: boolean;
124
+ readonly memberIndexes: readonly number[];
125
+ readonly resultIndexes: readonly number[];
126
+ }
127
+
128
+ /** True when an assistant message carries prose the user is meant to read. */
129
+ export function hasUserFacingProse(message: Message): boolean {
130
+ return message.role === 'assistant'
131
+ && typeof message.content === 'string'
132
+ && message.content.trim().length > 0;
133
+ }
134
+
135
+ function modelKeyOf(message: Extract<Message, { role: 'assistant' }>): string {
136
+ if (!message.model && !message.provider) return '';
137
+ return `${message.model ?? ''}|${message.provider ?? ''}`;
138
+ }
139
+
140
+ /**
141
+ * True when `message` must start a fresh `● assistant` header.
142
+ *
143
+ * A new header starts when there is no open run (a user message intervened),
144
+ * when the message carries user-facing prose (prose closes the current group
145
+ * and whatever follows begins a fresh one), or when the model/provider
146
+ * changed (a mid-run model switch is real information and stays visible).
147
+ *
148
+ * A system message does NOT break a run: operational status noise interleaves
149
+ * freely with tool activity, and breaking on it would fragment nearly every
150
+ * group back into the per-message headers this change removes.
151
+ */
152
+ function startsNewTurn(
153
+ message: Extract<Message, { role: 'assistant' }>,
154
+ run: { readonly modelKey: string } | null,
155
+ ): boolean {
156
+ if (run === null) return true;
157
+ if (hasUserFacingProse(message)) return true;
158
+ const key = modelKeyOf(message);
159
+ return key !== '' && run.modelKey !== '' && key !== run.modelKey;
160
+ }
161
+
162
+ interface OpenRun {
163
+ headIdx: number;
164
+ modelKey: string;
165
+ members: number[];
166
+ toolLabels: string[];
167
+ resultIndexes: number[];
168
+ hasReasoning: boolean;
169
+ }
170
+
171
+ export function computeAssistantTurns(
172
+ messages: readonly Message[],
173
+ indexOffset = 0,
174
+ ): ReadonlyMap<number, AssistantTurnMembership> {
175
+ const runs: OpenRun[] = [];
176
+ let open: OpenRun | null = null;
177
+ const callOwner = new Map<string, OpenRun>();
178
+
179
+ for (let i = 0; i < messages.length; i++) {
180
+ const message = messages[i]!;
181
+ const absIdx = indexOffset + i;
182
+
183
+ if (message.role === 'user') { open = null; continue; }
184
+ if (message.role === 'system') continue;
185
+
186
+ if (message.role === 'tool') {
187
+ const owner = message.callId ? callOwner.get(message.callId) : undefined;
188
+ if (owner) owner.resultIndexes.push(absIdx);
189
+ continue;
190
+ }
191
+
192
+ if (startsNewTurn(message, open)) {
193
+ open = { headIdx: absIdx, modelKey: modelKeyOf(message), members: [], toolLabels: [], resultIndexes: [], hasReasoning: false };
194
+ runs.push(open);
195
+ }
196
+ open!.members.push(absIdx);
197
+ if (open!.modelKey === '') open!.modelKey = modelKeyOf(message);
198
+ if (message.reasoningContent || message.reasoningSummary) open!.hasReasoning = true;
199
+ for (const call of message.toolCalls ?? []) {
200
+ open!.toolLabels.push(call.name);
201
+ if (call.id !== undefined) callOwner.set(call.id, open!);
202
+ }
203
+ }
204
+
205
+ const membership = new Map<number, AssistantTurnMembership>();
206
+ for (const run of runs) {
207
+ const distinct = new Set(run.toolLabels);
208
+ const shared = distinct.size === 1 && run.toolLabels.length >= 2 ? run.toolLabels[0] : undefined;
209
+ const base = {
210
+ turnKey: `turn_${run.headIdx}`,
211
+ headIdx: run.headIdx,
212
+ toolCallCount: run.toolLabels.length,
213
+ sharedToolLabel: shared,
214
+ hasReasoning: run.hasReasoning,
215
+ memberIndexes: run.members,
216
+ resultIndexes: run.resultIndexes,
217
+ } as const;
218
+ for (const absIdx of run.members) membership.set(absIdx, { ...base, isHead: absIdx === run.headIdx });
219
+ for (const absIdx of run.resultIndexes) membership.set(absIdx, { ...base, isHead: false });
220
+ }
221
+ return membership;
222
+ }
223
+
224
+ /** Resolve a spawned agent's live snapshot; return null when unavailable. */
225
+ export type AgentSnapshotResolver = (agentId: string) => readonly Message[] | null;
226
+
227
+ export interface RenderPlanOptions {
228
+ readonly resolveAgentSnapshot?: AgentSnapshotResolver | undefined;
229
+ readonly maxDepth?: number | undefined;
230
+ }
231
+
232
+ /**
233
+ * The agent id a tool call spawned, when it spawned one.
234
+ *
235
+ * Read from the call's own result payload first (the spawn tool reports the id
236
+ * it allocated — see AgentInput.agentId in the SDK's agent schema), falling
237
+ * back to an explicit `agentId` argument on the call. Returns undefined for
238
+ * every ordinary tool call, which is the overwhelmingly common case, so this
239
+ * stays a cheap check.
240
+ */
241
+ function spawnedAgentIdOf(
242
+ call: { readonly name: string; readonly arguments: Record<string, unknown> },
243
+ results: readonly Message[],
244
+ ): string | undefined {
245
+ for (const result of results) {
246
+ if (result.role !== 'tool') continue;
247
+ const raw = result.content;
248
+ if (typeof raw !== 'string' || !raw.includes('agentId')) continue;
249
+ try {
250
+ const parsed: unknown = JSON.parse(raw);
251
+ if (parsed && typeof parsed === 'object') {
252
+ const id = (parsed as Record<string, unknown>).agentId;
253
+ if (typeof id === 'string' && id.length > 0) return id;
254
+ }
255
+ } catch {
256
+ // Non-JSON result — fall through to the argument check.
257
+ }
258
+ }
259
+ const argId = call.arguments.agentId;
260
+ return typeof argId === 'string' && argId.length > 0 ? argId : undefined;
261
+ }
262
+
263
+ /** Internal tree node; flattened into RenderNode[] once sibling counts are known. */
264
+ interface TreeNode {
265
+ id: string;
266
+ kind: 'message' | 'toolcall';
267
+ absIdx: number;
268
+ callIndex?: number;
269
+ message: Message;
270
+ scope: string;
271
+ agentId?: string;
272
+ truncated?: 'depth' | 'cycle';
273
+ children: TreeNode[];
274
+ }
275
+
276
+ /**
277
+ * Build the subtree for one snapshot (root transcript or a nested agent's).
278
+ * Returns the top-level nodes, each already carrying its own children.
279
+ */
280
+ function buildScopeNodes(
281
+ messages: readonly Message[],
282
+ indexOffset: number,
283
+ scope: string,
284
+ agentId: string | undefined,
285
+ depth: number,
286
+ opts: RenderPlanOptions,
287
+ ancestorAgentIds: readonly string[],
288
+ ): TreeNode[] {
289
+ const maxDepth = opts.maxDepth ?? MAX_NEST_DEPTH;
290
+
291
+ const resultsByCallId = new Map<string, number[]>();
292
+ for (let i = 0; i < messages.length; i++) {
293
+ const message = messages[i]!;
294
+ if (message.role !== 'tool' || !message.callId) continue;
295
+ const bucket = resultsByCallId.get(message.callId);
296
+ if (bucket) bucket.push(indexOffset + i);
297
+ else resultsByCallId.set(message.callId, [indexOffset + i]);
298
+ }
299
+
300
+ const nodes: TreeNode[] = [];
301
+ const placed = new Set<number>();
302
+ const messageAt = (absIdx: number): Message => messages[absIdx - indexOffset]!;
303
+ // Calls issued by later messages of the SAME run attach to the run's head
304
+ // node, because the head owns the only visible header — that is what makes
305
+ // them siblings of each other rather than only children of their own
306
+ // (headerless) message, and therefore what makes ├/└ resolve correctly
307
+ // across a merged turn.
308
+ const turns = computeAssistantTurns(messages, indexOffset);
309
+ const headNodes = new Map<string, TreeNode>();
310
+
311
+ for (let i = 0; i < messages.length; i++) {
312
+ const message = messages[i]!;
313
+ const absIdx = indexOffset + i;
314
+
315
+ if (message.role === 'tool') {
316
+ if (placed.has(absIdx)) continue;
317
+ nodes.push({ id: `${scope}m:${absIdx}`, kind: 'message', absIdx, message, scope, agentId, children: [] });
318
+ continue;
319
+ }
320
+
321
+ if (message.role !== 'assistant') {
322
+ nodes.push({ id: `${scope}m:${absIdx}`, kind: 'message', absIdx, message, scope, agentId, children: [] });
323
+ continue;
324
+ }
325
+
326
+ const head: TreeNode = { id: `${scope}m:${absIdx}`, kind: 'message', absIdx, message, scope, agentId, children: [] };
327
+ nodes.push(head);
328
+
329
+ const turn = turns.get(absIdx);
330
+ if (turn?.isHead) headNodes.set(turn.turnKey, head);
331
+ // Non-head members hang their calls off the run's head so the whole turn's
332
+ // activity is one sibling list under one header.
333
+ const callParent = turn ? (headNodes.get(turn.turnKey) ?? head) : head;
334
+
335
+ const calls = message.toolCalls ?? [];
336
+ for (let k = 0; k < calls.length; k++) {
337
+ const call = calls[k]!;
338
+ const callNode: TreeNode = {
339
+ id: `${scope}c:${absIdx}:${k}`,
340
+ kind: 'toolcall',
341
+ absIdx,
342
+ callIndex: k,
343
+ message,
344
+ scope,
345
+ agentId,
346
+ children: [],
347
+ };
348
+ callParent.children.push(callNode);
349
+
350
+ const resultIdxs = call.id === undefined ? [] : (resultsByCallId.get(call.id) ?? []);
351
+ const resultMessages: Message[] = [];
352
+ for (const resultIdx of resultIdxs) {
353
+ if (placed.has(resultIdx)) continue;
354
+ placed.add(resultIdx);
355
+ const resultMessage = messageAt(resultIdx);
356
+ resultMessages.push(resultMessage);
357
+ callNode.children.push({
358
+ id: `${scope}m:${resultIdx}`,
359
+ kind: 'message',
360
+ absIdx: resultIdx,
361
+ message: resultMessage,
362
+ scope,
363
+ agentId,
364
+ children: [],
365
+ });
366
+ }
367
+
368
+ // Splice a spawned agent's own plan in beneath the call that spawned it.
369
+ const childAgentId = opts.resolveAgentSnapshot
370
+ ? spawnedAgentIdOf(call, resultMessages)
371
+ : undefined;
372
+ if (!childAgentId) continue;
373
+ if (ancestorAgentIds.includes(childAgentId)) {
374
+ callNode.truncated = 'cycle';
375
+ continue;
376
+ }
377
+ // A spliced child scope's own rows start two levels below this call
378
+ // (head, then its calls, then their results), so the deepest row it can
379
+ // contribute sits at depth+4. Guard on that, not on the base, or the
380
+ // ceiling is overshot by exactly those two levels.
381
+ if (depth + 4 > maxDepth) {
382
+ callNode.truncated = 'depth';
383
+ continue;
384
+ }
385
+ const childMessages = opts.resolveAgentSnapshot!(childAgentId);
386
+ if (!childMessages || childMessages.length === 0) continue;
387
+ const childScope = `${scope}a:${childAgentId}/`;
388
+ callNode.children.push(
389
+ ...buildScopeNodes(childMessages, 0, childScope, childAgentId, depth + 2, opts, [...ancestorAgentIds, childAgentId]),
390
+ );
391
+ }
392
+ }
393
+
394
+ return nodes;
395
+ }
396
+
397
+ /**
398
+ * Flatten the tree depth-first, resolving each row's connector and the `│`
399
+ * gutters of ancestors whose subtrees are still open.
400
+ *
401
+ * `openAncestorDepths` is computed from live structure on every call, so a row
402
+ * that stops being last flips `└`→`├` on the next rebuild with no cached state
403
+ * to go stale.
404
+ */
405
+ function flatten(
406
+ nodes: readonly TreeNode[],
407
+ depth: number,
408
+ openAncestorDepths: readonly number[],
409
+ out: RenderNode[],
410
+ ): void {
411
+ for (let i = 0; i < nodes.length; i++) {
412
+ const node = nodes[i]!;
413
+ const isLast = i === nodes.length - 1;
414
+ const connector: TreeConnector | undefined = depth === 0 ? undefined : (isLast ? '└' : '├');
415
+
416
+ out.push({
417
+ id: node.id,
418
+ kind: node.kind,
419
+ absIdx: node.absIdx,
420
+ callIndex: node.callIndex,
421
+ depth,
422
+ message: node.message,
423
+ scope: node.scope,
424
+ agentId: node.agentId,
425
+ connector,
426
+ openAncestorDepths,
427
+ truncated: node.truncated,
428
+ });
429
+
430
+ if (node.children.length === 0) continue;
431
+ // This row's subtree continues below it only when it is NOT the last
432
+ // sibling; that is exactly when descendants must draw `│` in this depth's
433
+ // gutter to show the parent chain is still open.
434
+ const childOpen = depth === 0 || isLast ? openAncestorDepths : [...openAncestorDepths, depth];
435
+ flatten(node.children, depth + 1, childOpen, out);
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Build the ordered render plan for a message slice.
441
+ *
442
+ * Every message appears exactly once, plus one node per tool call, plus the
443
+ * spliced-in nodes of any agent a call spawned.
444
+ */
445
+ export function buildRenderPlan(
446
+ messages: readonly Message[],
447
+ indexOffset = 0,
448
+ opts: RenderPlanOptions = {},
449
+ ): RenderNode[] {
450
+ const tree = buildScopeNodes(messages, indexOffset, '', undefined, 0, opts, []);
451
+ const plan: RenderNode[] = [];
452
+ flatten(tree, 0, [], plan);
453
+ return plan;
454
+ }
455
+
456
+ /** Collapse key for a planned row, scope-aware. Root scope keys are unchanged. */
457
+ export function collapseKeyForNode(node: RenderNode): string {
458
+ return `msg_${node.scope}${node.absIdx}`;
459
+ }
460
+
461
+ /** Collapse key for an entire spawned-agent subtree. */
462
+ export function agentSubtreeCollapseKey(agentId: string): string {
463
+ return `agent_${agentId}`;
464
+ }
@@ -22,7 +22,7 @@ import type { BlockMeta as SdkBlockMeta } from '@pellux/goodvibes-sdk/platform/c
22
22
  * variant without touching the SDK's published type.
23
23
  */
24
24
  export type BlockMeta = Omit<SdkBlockMeta, 'type'> & {
25
- type: SdkBlockMeta['type'] | 'tool_group';
25
+ type: SdkBlockMeta['type'] | 'assistant_turn';
26
26
  /** Index of this block (increments per renderable block). */
27
27
  blockIndex: number;
28
28
  /** First rendered line index in the history buffer. */
@@ -32,17 +32,16 @@ export type BlockMeta = Omit<SdkBlockMeta, 'type'> & {
32
32
  /** Stable key for collapse state persistence across rebuilds (e.g. msg_N). */
33
33
  collapseKey: string;
34
34
  /**
35
- * Absolute message indexes of every member of a folded tool-result group
36
- * (see conversation-tool-groups.ts). Present only on 'tool_group' blocks —
37
- * lets /expand also open each member's own collapse key in the same pass,
38
- * since a folded member pushes no BlockMeta of its own to toggle
39
- * individually while the group stays collapsed.
35
+ * Absolute message indexes of every tool result hanging under an
36
+ * 'assistant_turn' header. A row hidden by a collapsed turn pushes no
37
+ * BlockMeta of its own, so this list is what lets /expand reopen each
38
+ * result's own collapse key in the same pass, and what lets search reach
39
+ * content that is currently hidden.
40
40
  */
41
41
  groupMemberIndexes?: readonly number[];
42
42
  /**
43
- * The tool call's name, when this block renders a 'tool' result (or is the
44
- * header of a 'tool_group' whose members share a run — the header carries
45
- * the first member's name). Used to name the block honestly in action
43
+ * The tool call's name, when this block renders a 'tool' result (or is an
44
+ * 'assistant_turn' header whose calls all share one label). Used to name the block honestly in action
46
45
  * receipts (e.g. "tool result: exec") — see describeBlockForReceipt in
47
46
  * handler-content-actions.ts. Undefined for non-tool block types and for
48
47
  * standalone tool results with no recorded name.
package/src/daemon/cli.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
- import { ConfigManager, resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import {
4
+ ConfigManager,
5
+ deriveControlPlaneBaseUrl,
6
+ readControlPlaneBinding,
7
+ resolveDaemonEnabled,
8
+ } from '@pellux/goodvibes-sdk/platform/config';
9
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
4
10
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
5
11
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
6
12
  import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
@@ -30,6 +36,7 @@ import {
30
36
  import { createSafeHostServeFactory } from './safe-serve.ts';
31
37
  import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
32
38
  import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
39
+ import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
33
40
  import { reconcileRedundantLegacyUnit } from '../runtime/legacy-daemon-reconcile.ts';
34
41
  import { resolveRuntimeEndpointBinding } from '../cli/endpoints.ts';
35
42
  import { resolveDaemonUpdateArtifact } from './lifecycle.ts';
@@ -47,7 +54,10 @@ import {
47
54
  } from '../cli/index.ts';
48
55
  type DaemonCliOwnership = {
49
56
  readonly workingDirectory: string;
57
+ /** The GoodVibes tree root — settings, workspace, and discovery all hang off this. */
50
58
  readonly homeDirectory: string;
59
+ /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
60
+ readonly daemonHomeDirectory: string;
51
61
  };
52
62
 
53
63
  // CLI flag parsing delegated to shared module — see src/cli-flags.ts
@@ -57,10 +67,30 @@ type DaemonCliTokens = {
57
67
  readonly httpToken: string | undefined;
58
68
  };
59
69
 
70
+ /**
71
+ * Two different directories that used to be one.
72
+ *
73
+ * `GOODVIBES_DAEMON_HOME` names the DAEMON's home — the identity directory
74
+ * holding auth-users.json, operator-tokens.json, and daemon-settings.json. That
75
+ * is what the name says and what the SDK's `resolveDaemonHomeDir()` has always
76
+ * meant by it. This function used to read it as the GoodVibes tree ROOT, so
77
+ * setting it relocated settings, workspace, and every discovery root as well —
78
+ * far more than the daemon's own state.
79
+ *
80
+ * `GOODVIBES_HOME` is the variable for relocating the tree root. It is what a
81
+ * test harness or a service unit should set when it wants an isolated tree; the
82
+ * daemon home then falls under it unless separately overridden.
83
+ */
60
84
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
85
+ const homeDirectory = process.env['GOODVIBES_HOME']?.trim() || homedir();
86
+ const daemonHomeOverride = process.env['GOODVIBES_DAEMON_HOME']?.trim();
87
+ const daemonHomeDirectory = daemonHomeOverride
88
+ ? (isAbsolute(daemonHomeOverride) ? daemonHomeOverride : resolve(process.cwd(), daemonHomeOverride))
89
+ : join(homeDirectory, '.goodvibes', 'daemon');
61
90
  return {
62
91
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
63
- homeDirectory: process.env['GOODVIBES_DAEMON_HOME'] ?? homedir(),
92
+ homeDirectory,
93
+ daemonHomeDirectory,
64
94
  };
65
95
  }
66
96
 
@@ -105,7 +135,8 @@ async function main(): Promise<void> {
105
135
  logger.info('daemon: --working-dir flag applied', { workingDir: cliFlags.workingDir });
106
136
  }
107
137
 
108
- const { workingDirectory: workingDir, homeDirectory } = resolveDaemonCliOwnership();
138
+ const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory } = resolveDaemonCliOwnership();
139
+ runDaemonConfigMigration(homeDirectory);
109
140
  const config = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
110
141
  new GlobalNetworkTransportInstaller().install(config);
111
142
 
@@ -200,6 +231,40 @@ async function main(): Promise<void> {
200
231
  );
201
232
  }
202
233
 
234
+ // Boot-time reconciliation. The banner above states the real bind; this
235
+ // compares it to the URL clients are actually handed. Those are produced by
236
+ // two different resolvers, and when they disagree the daemon is advertising an
237
+ // address it does not answer on — the state that produced two different click
238
+ // hosts from one daemon. Only reported when it is genuinely wrong: a wildcard
239
+ // bind against a loopback dial target is a deliberate substitution, and a
240
+ // declared controlPlane.publicBaseUrl is supposed to differ from the bind.
241
+ // The derivation itself is the SDK's (deriveControlPlaneBaseUrl); only the
242
+ // comparison is local. The SDK also carries describeDerivedBindMismatch,
243
+ // which does exactly this — swap to it once a release ships it, since the
244
+ // published 1.14.0 this build consumes predates it.
245
+ if (bannerBinding.recognized) {
246
+ const derived = new URL(
247
+ deriveControlPlaneBaseUrl(readControlPlaneBinding((key) => config.get(key as ConfigKey)), 'loopback'),
248
+ );
249
+ // A wildcard bind is REPORTED as 0.0.0.0 while the dial target is loopback;
250
+ // that substitution is deliberate, not drift.
251
+ const wildcardBind = bannerBinding.host === '0.0.0.0' || bannerBinding.host === '::';
252
+ const hostAgrees = derived.hostname === bannerBinding.host
253
+ || (wildcardBind && derived.hostname === '127.0.0.1');
254
+ if (!hostAgrees || Number(derived.port) !== bannerBinding.port) {
255
+ console.warn(
256
+ `[goodvibes-daemon] warning: control-plane clients are handed ${derived.origin}, but the daemon `
257
+ + `actually bound ${bannerBinding.host}:${bannerBinding.port}. One of these is wrong, and anything `
258
+ + 'given the first will dial a place this daemon does not answer.',
259
+ );
260
+ logger.warn('daemon: control-plane base URL disagrees with the real bind', {
261
+ handedToClients: derived.origin,
262
+ boundHost: bannerBinding.host,
263
+ boundPort: bannerBinding.port,
264
+ });
265
+ }
266
+ }
267
+
203
268
  const runtimeBus = new RuntimeEventBus();
204
269
  const runtimeStore = createRuntimeStore();
205
270
  // Gate states derive from the domain settings keys; the bridge keeps live
@@ -272,7 +337,7 @@ async function main(): Promise<void> {
272
337
  const { daemonToken, httpToken } = readDaemonCliTokens(process.env);
273
338
 
274
339
  // If no explicit daemon token is set, use the companion token so mobile apps can connect.
275
- const daemonHomeDir = join(homeDirectory, '.goodvibes', 'daemon');
340
+ const daemonHomeDir = daemonHomeDirectory;
276
341
  const companionTokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
277
342
  // Fix (TUI 0.19.20): remove stale pre-0.21.28 workspace-scoped operator
278
343
  // token files so only the canonical <daemonHomeDir>/operator-tokens.json survives.
@@ -348,6 +413,7 @@ async function main(): Promise<void> {
348
413
  // settings.json with none of this process's runtime flag overrides — because
349
414
  // that is what clients resolve when they look for the daemon.
350
415
  try {
416
+ runDaemonConfigMigration(homeDirectory);
351
417
  const clientViewConfig = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
352
418
  const clientEndpoint = resolveRuntimeEndpointBinding(clientViewConfig, 'controlPlane');
353
419
  const reconcile = await reconcileRedundantLegacyUnit({
@@ -34,27 +34,27 @@ function toggleBlocks(typeFilter: string, collapsed: boolean, ctx: CommandContex
34
34
  let count = 0;
35
35
  for (let i = 0; i < blockRegistry.length; i++) {
36
36
  const block = blockRegistry[i];
37
- // A folded tool-result group (type 'tool_group', see conversation-tool-groups.ts)
38
- // is a run of tool results under one header, so '/expand tool'/'collapse tool'
39
- // treats it the same as a plain 'tool' block — toggling the header's collapse
40
- // key expands/collapses every member underneath it.
37
+ // A merged assistant turn (type 'assistant_turn', see
38
+ // conversation-turn-structure.ts) owns the whole tool subtree beneath its
39
+ // header, so '/expand tool'/'collapse tool' treats it the same as a plain
40
+ // 'tool' block — toggling the header's collapse key shows or hides every
41
+ // tool row underneath it.
41
42
  const matchesType = typeFilter === 'all'
42
- || (typeFilter === 'tool' && (block.type === 'tool' || block.type === 'tool_group'))
43
+ || (typeFilter === 'tool' && (block.type === 'tool' || block.type === 'assistant_turn'))
43
44
  || (typeFilter === 'code' && block.type === 'code')
44
45
  || (typeFilter === 'thinking' && block.type === 'thinking');
45
46
  if (!matchesType) continue;
46
47
  const isCurrentlyCollapsed = ctx.session.conversationManager.isCollapsed(i);
47
48
  if (collapsed ? !isCurrentlyCollapsed : isCurrentlyCollapsed) {
48
49
  ctx.session.conversationManager.toggleCollapseAtLine(block.startLine);
49
- // Expanding a folded tool-result group also expands every member's own
50
- // collapse key in the same pass. A folded member pushes no BlockMeta of
51
- // its own while the group is collapsed, so it never surfaces from this
52
- // loop to be toggled individually — without this, '/expand tool' only
53
- // opens the group header and each member still renders at whatever its
54
- // own default collapse state is, needing a second '/expand tool' pass.
55
- // '/collapse tool' needs no matching step: collapsing the group hides
56
- // every member regardless of its own key.
57
- if (!collapsed && block.type === 'tool_group' && block.groupMemberIndexes) {
50
+ // Expanding a turn also expands each result's own collapse key in the
51
+ // same pass. A result hidden by a collapsed turn pushes no BlockMeta of
52
+ // its own, so it never surfaces from this loop to be toggled
53
+ // individually — without this, '/expand tool' would only open the turn
54
+ // header and each result would still render at its own default collapse
55
+ // state, needing a second pass. '/collapse tool' needs no matching step:
56
+ // collapsing the turn hides every result regardless of its own key.
57
+ if (!collapsed && block.type === 'assistant_turn' && block.groupMemberIndexes) {
58
58
  for (const memberIdx of block.groupMemberIndexes) {
59
59
  const memberKey = `msg_${memberIdx}`;
60
60
  ctx.session.conversationManager.setCollapsed(memberKey, false);