Skip to content

Commit 2b2f438

Browse files
authored
feat: add session advisor and transcript rendering updates (#75)
## Related Issue No issue — this branch turns previously uncommitted working-tree changes into reviewable, dependency-ordered commits. ## Problem The working tree carried two finished features with no commit history: a session advisor (second-opinion review) and transcript rendering changes (per-child render metadata, live thinking in the activity pane). They needed to land as small reviewable commits instead of one bulk change, and the TUI changes depended on the backend chain (agent-core → protocol → node-sdk) being built first. ## What changed - **Session advisor**: new WATCHDOG-configured multi-advisor runtime in agent-core — one reviewer agent per configured advisor (`WATCHDOG.md` / `WATCHDOG.yml` in user and project scopes), per-advisor state (status, enable overrides, consecutive-failure limits, tool selection) and JSONL transcripts of notes and cost. Falls back to the legacy single-advisor config when no WATCHDOG files exist. Sessions gain an `emitEvents` option for advisor subagents and close the advisor on shutdown. - **Context revision tracking** in agent-core so the advisor can detect history rewrites and resync its view. - **Protocol**: new `advisor.status` event schema. - **Session RPC + SDK**: `getAdvisorStatus` / `setAdvisorEnabled` / `reloadAdvisor` on the session API and a `SessionAdvisor` facade in the node-sdk client. - **TUI**: new `/advisor` slash command (`status`, `on`, `off`, `toggle`, `reload`, optional advisor id) and rendering of `advisor.status` events; parity matrix updated. - **Transcript container refactor**: per-child role (durable / live-durable / ephemeral) and edge-blank metadata, exact row accounting for scroll math. - **Live thinking** now streams in the activity pane and is promoted into the transcript on completion; the shared tool-output toggle expands it. - Running dynamic workflow rows use the shared braille spinner frames. Tests were added or updated across agent-core, protocol, node-sdk, and the TUI; both oxlint passes are clean on all changed files. Changesets are included (minor for the advisor feature, patches for the rendering changes), and the `/advisor` command is documented in the slash command reference. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added multi-advisor support with status monitoring, enable/disable, reload, configuration discovery, and failure tracking. * Added SDK controls and status events for Session Advisors. * Added the `/advisor` command with completion and detailed status output. * Added live thinking display in the activity pane, moving completed thinking into the transcript. * **UI Improvements** * Improved transcript spacing, separators, ephemeral messages, and expandable output handling. * Standardized workflow progress indicators with braille spinner animation. * **Documentation** * Documented Session Advisor commands and availability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4b6d10f commit 2b2f438

60 files changed

Lines changed: 2704 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/sdk-advisor-api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code-sdk": minor
3+
---
4+
5+
Add advisor status and control methods to the session client.

.changeset/session-advisor.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add a session advisor that reviews work in the background, with the /advisor command to show and control it.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Stream in-progress thinking in the activity pane and move it into the transcript when complete.

.changeset/workflow-spinner.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Show running dynamic workflow rows with the same braille spinner glyphs as other loaders.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { formatErrorMessage, type AdvisorStatusSnapshot } from '@pymodel/pythinker-code-sdk';
2+
import type { SlashCommandHost } from './dispatch';
3+
4+
const ADVISOR_STATUS_GLYPHS: Record<string, string> = {
5+
running: '●',
6+
paused: '○',
7+
no_model: '○',
8+
quota_exhausted: '✕',
9+
error: '✕',
10+
};
11+
12+
const ADVISOR_STATUS_LABELS: Record<string, string> = {
13+
running: 'running',
14+
paused: 'off',
15+
no_model: 'no model',
16+
quota_exhausted: 'quota exhausted',
17+
error: 'error',
18+
};
19+
20+
export async function handleAdvisorCommand(host: SlashCommandHost, args: string): Promise<void> {
21+
const parts = args.trim().split(/\s+/u).filter(Boolean);
22+
const verb = parts[0] ?? 'status';
23+
const advisorId = parts[1];
24+
if (parts.length > 2 || !['on', 'off', 'reload', 'status', 'toggle'].includes(verb)) {
25+
host.showError('Usage: /advisor [on|off|status|reload|toggle] [advisor-id]');
26+
return;
27+
}
28+
if (host.session === undefined) {
29+
host.showError('No active session.');
30+
return;
31+
}
32+
33+
if (verb === 'status') {
34+
host.showNotice('Advisor status', formatAdvisorStatuses(await host.session.advisor.status()));
35+
return;
36+
}
37+
if (verb === 'reload') {
38+
await host.session.advisor.reload();
39+
host.showStatus('Advisor configuration reloaded.');
40+
return;
41+
}
42+
43+
const statuses = await host.session.advisor.status();
44+
if (advisorId !== undefined && !statuses.some((status) => status.id === advisorId)) {
45+
host.showError(
46+
`Unknown advisor: ${advisorId}. Run /advisor status to list configured advisors.`,
47+
);
48+
return;
49+
}
50+
const enabled =
51+
verb === 'toggle'
52+
? !(
53+
statuses.find((status) =>
54+
advisorId === undefined ? true : status.id === advisorId,
55+
)?.enabled ?? false
56+
)
57+
: verb === 'on';
58+
let updatedStatuses: readonly AdvisorStatusSnapshot[];
59+
try {
60+
updatedStatuses = await host.session.advisor.setEnabled(enabled, advisorId);
61+
} catch (error) {
62+
host.showError(formatErrorMessage(error));
63+
return;
64+
}
65+
const target = advisorId === undefined ? 'Advisor' : `Advisor ${advisorId}`;
66+
const applied =
67+
advisorId === undefined
68+
? updatedStatuses.length > 0 &&
69+
updatedStatuses.every((status) => status.enabled === enabled)
70+
: updatedStatuses.find((status) => status.id === advisorId)?.enabled === enabled;
71+
if (!applied) {
72+
host.showError(`${target} remains ${enabled ? 'disabled' : 'enabled'}.`);
73+
return;
74+
}
75+
host.showStatus(`${target} ${enabled ? 'enabled' : 'disabled'}.`);
76+
}
77+
78+
function formatAdvisorStatuses(statuses: readonly AdvisorStatusSnapshot[]): string {
79+
if (statuses.length === 0) return 'Advisor is disabled.';
80+
return statuses
81+
.map((advisor) => {
82+
const glyph = ADVISOR_STATUS_GLYPHS[advisor.status] ?? '?';
83+
const label = ADVISOR_STATUS_LABELS[advisor.status] ?? advisor.status;
84+
const model = advisor.model === undefined ? '' : `\n Model: ${advisor.model}`;
85+
const details = `\n ${advisor.notes} notes · $${advisor.costUsd.toFixed(4)} · ${advisor.failures} failures`;
86+
const message = advisor.message === undefined ? '' : `\n ${advisor.message}`;
87+
return `${glyph} ${advisor.name} [${label}]${advisor.enabled ? '' : ' (disabled)'}${model}${details}${message}`;
88+
})
89+
.join('\n\n');
90+
}

apps/pythinker-code/src/tui/commands/diff.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ async function showWorkingTreeFileDiff(
117117
'primary',
118118
' Diff ',
119119
);
120-
host.state.transcriptContainer.addChild(panel);
120+
host.state.transcriptContainer.addTranscriptChild(panel, {
121+
role: 'ephemeral',
122+
edgeBlankPolicy: 'preserve',
123+
});
121124
host.state.ui.requestRender();
122125
} catch (error) {
123126
host.showError(`Failed to load diff for ${path}: ${formatErrorMessage(error)}`);

apps/pythinker-code/src/tui/commands/dispatch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
TranscriptEntry,
2020
} from '../types';
2121
import { formatErrorMessage } from '../utils/event-payload';
22+
import { handleAdvisorCommand } from './advisor';
2223
import { handleAddDirCommand } from './add-dir';
2324
import { handleAgentsCommand } from './agents';
2425
import { handleLoginCommand, handleLogoutCommand } from './auth';
@@ -388,6 +389,9 @@ async function handleBuiltInSlashCommand(
388389
case 'fast':
389390
await handleFastCommand(host, args);
390391
return;
392+
case 'advisor':
393+
await handleAdvisorCommand(host, args);
394+
return;
391395
case 'provider':
392396
await handleProviderCommand(host);
393397
return;

apps/pythinker-code/src/tui/commands/dynamic-workflow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,9 @@ function dynamicWorkflowModeSubcommand(input: string): boolean | undefined {
286286
}
287287

288288
function renderDynamicWorkflowModeMarker(host: SlashCommandHost, state: DynamicWorkflowModeMarkerState): void {
289-
host.state.transcriptContainer.addChild(
289+
host.state.transcriptContainer.addTranscriptChild(
290290
new DynamicWorkflowModeMarkerComponent(state),
291+
{ role: 'ephemeral', edgeBlankPolicy: 'preserve' },
291292
);
292293
host.state.ui.requestRender();
293294
}

apps/pythinker-code/src/tui/commands/goal.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,9 @@ async function queueNextGoal(
205205
}
206206
host.track('goal_queue_append');
207207
if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.();
208-
host.state.transcriptContainer.addChild(
208+
host.state.transcriptContainer.addTranscriptChild(
209209
new UpcomingGoalAddedMessageComponent(),
210+
{ role: 'ephemeral', edgeBlankPolicy: 'preserve' },
210211
);
211212
host.state.ui.requestRender();
212213
}
@@ -413,7 +414,10 @@ async function startGoal(
413414
return false;
414415
}
415416
host.track('goal_create', { replace: parsed.replace });
416-
host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
417+
host.state.transcriptContainer.addTranscriptChild(new GoalSetMessageComponent(), {
418+
role: 'ephemeral',
419+
edgeBlankPolicy: 'preserve',
420+
});
417421
host.state.ui.requestRender();
418422
if (options.sendInput !== undefined) {
419423
options.sendInput(parsed.objective);
@@ -484,9 +488,10 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
484488
host.showStatus('No goal set. Start one with `/goal <objective>`.');
485489
return;
486490
}
487-
host.state.transcriptContainer.addChild(
488-
new GoalStatusMessageComponent(goal),
489-
);
491+
host.state.transcriptContainer.addTranscriptChild(new GoalStatusMessageComponent(goal), {
492+
role: 'ephemeral',
493+
edgeBlankPolicy: 'preserve',
494+
});
490495
host.state.ui.requestRender();
491496
}
492497

apps/pythinker-code/src/tui/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
export { handleCopyCommand, showMessageActions } from './copy';
3131
export { handleDebugCommand } from './debug';
3232
export { buildWorkingTreeDiffLines, handleDiffCommand } from './diff';
33+
export { handleAdvisorCommand } from './advisor';
3334
export { handleDynamicWorkflowCommand } from './dynamic-workflow';
3435
export { handleFastCommand } from './fast';
3536
export {

0 commit comments

Comments
 (0)