@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +714 -177
- package/docs/foundation-artifacts/peer-contract.json +8 -4
- package/package.json +9 -7
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/service-posture.ts +6 -2
- package/src/config/run-daemon-config-migration.ts +46 -0
- package/src/core/conversation-compaction-render.ts +88 -0
- package/src/core/conversation-line-cache.ts +120 -92
- package/src/core/conversation-rendering.ts +290 -233
- package/src/core/conversation-turn-structure.ts +464 -0
- package/src/core/conversation-types.ts +8 -9
- package/src/daemon/cli.ts +71 -5
- package/src/input/commands/local-runtime.ts +14 -14
- package/src/input/commands/operator-rpc.ts +15 -4
- package/src/input/daemon-owned-settings-descriptions.ts +53 -0
- package/src/input/handler-content-actions.ts +4 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
- package/src/input/onboarding/onboarding-feature-units.ts +38 -0
- package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
- package/src/input/search.ts +1 -1
- package/src/input/settings-modal-data.ts +15 -85
- package/src/input/settings-modal-search.ts +99 -0
- package/src/input/settings-modal-types.ts +3 -1
- package/src/renderer/conversation-surface.ts +37 -11
- package/src/renderer/conversation-tree.ts +179 -0
- package/src/renderer/settings-modal-helpers.ts +56 -2
- package/src/renderer/settings-modal.ts +9 -50
- package/src/renderer/tool-call.ts +38 -5
- package/src/runtime/cloudflare-control-plane.ts +4 -1
- package/src/runtime/feature-settings.ts +7 -0
- package/src/runtime/index.ts +22 -1
- package/src/runtime/legacy-daemon-migration.ts +2 -0
- package/src/verification/verification-ledger.ts +115 -2
- package/src/version.ts +1 -1
- 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'] | '
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
44
|
-
*
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
|
38
|
-
//
|
|
39
|
-
// treats it the same as a plain
|
|
40
|
-
// key
|
|
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 === '
|
|
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
|
|
50
|
-
//
|
|
51
|
-
// its own
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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);
|