Skip to content

Commit d234113

Browse files
committed
fix: session title annotations with status icons and reset cooldown
Use distinct icons per event type (✅ complete, ❌ failed, 🚧 blocked, 👀 needs_review, ❓ question) prefixed to the original session title. Add cooldown-based reset suppression so sendMessage hooks don't immediately undo the annotation. Title resets when the user actually engages with the session after the cooldown expires. Also adds mc_debug_title tool for testing title updates via SDK.
1 parent 0e76440 commit d234113

File tree

4 files changed

+201
-29
lines changed

4 files changed

+201
-29
lines changed

src/hooks/notifications.ts

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ import { type PendingQuestion, buildQuestionRelayMessage } from '../lib/question
77
type Client = PluginInput['client'];
88
type NotificationEvent = 'complete' | 'failed' | 'blocked' | 'needs_review' | 'awaiting_input' | 'question';
99

10+
// Tracks when we last annotated a title per session. Resets are suppressed
11+
// for a cooldown period because sendMessage triggers chat.message hooks
12+
// asynchronously, which would immediately undo the annotation we just set.
13+
const TITLE_RESET_COOLDOWN_MS = 5_000;
14+
const lastAnnotationTime = new Map<string, number>();
15+
16+
const mcPrefixedSessions = new Set<string>();
17+
18+
export function isTitleResetSuppressed(sessionID: string): boolean {
19+
const t = lastAnnotationTime.get(sessionID);
20+
if (!t) return false;
21+
return Date.now() - t < TITLE_RESET_COOLDOWN_MS;
22+
}
23+
1024
interface JobMonitorLike {
1125
on(event: NotificationEvent, handler: (job: Job, ...extra: unknown[]) => void): void;
1226
}
@@ -40,13 +54,24 @@ function extractSessionTitle(response: unknown): string | undefined {
4054
return undefined;
4155
}
4256

43-
function buildAnnotatedTitle(state: SessionTitleState): string {
44-
if (state.annotations.size === 0) return state.originalTitle;
57+
const STATUS_ICONS: Record<string, string> = {
58+
complete: '✅',
59+
failed: '❌',
60+
blocked: '🚧',
61+
needs_review: '👀',
62+
awaiting_input: '❓',
63+
question: '❓',
64+
};
65+
66+
function buildAnnotatedTitle(state: SessionTitleState, sessionID: string): string {
67+
const mcPrefix = mcPrefixedSessions.has(sessionID) ? '[MC] ' : '';
68+
if (state.annotations.size === 0) return `${mcPrefix}${state.originalTitle}`;
4569
if (state.annotations.size === 1) {
46-
const [[jobName, statusText]] = [...state.annotations.entries()];
47-
return `${jobName} ${statusText}`;
70+
const [[, status]] = [...state.annotations.entries()];
71+
const icon = STATUS_ICONS[status] ?? '🔔';
72+
return `${mcPrefix}${icon} ${state.originalTitle}`;
4873
}
49-
return `${state.annotations.size} jobs need attention`;
74+
return `${mcPrefix}🔔 ${state.originalTitle}`;
5075
}
5176

5277
export async function annotateSessionTitle(
@@ -60,7 +85,10 @@ export async function annotateSessionTitle(
6085
try {
6186
if (!titleState.has(sessionID)) {
6287
const session = await client.session.get({ path: { id: sessionID } });
63-
const originalTitle = extractSessionTitle(session) ?? '';
88+
let originalTitle = extractSessionTitle(session) ?? '';
89+
if (originalTitle.startsWith('[MC] ')) {
90+
originalTitle = originalTitle.slice(5);
91+
}
6492
titleState.set(sessionID, {
6593
originalTitle,
6694
annotations: new Map(),
@@ -69,12 +97,13 @@ export async function annotateSessionTitle(
6997

7098
const state = titleState.get(sessionID)!;
7199
state.annotations.set(jobName, statusText);
72-
const annotatedTitle = buildAnnotatedTitle(state);
100+
const annotatedTitle = buildAnnotatedTitle(state, sessionID);
73101

74102
await client.session.update({
75103
path: { id: sessionID },
76104
body: { title: annotatedTitle },
77105
});
106+
lastAnnotationTime.set(sessionID, Date.now());
78107
} catch {
79108
// Fire-and-forget: don't block on title update failures
80109
}
@@ -84,13 +113,14 @@ export async function resetSessionTitle(client: Client, sessionID: string): Prom
84113
const state = titleState.get(sessionID);
85114
if (!state) return;
86115

87-
const originalTitle = state.originalTitle;
116+
const mcPrefix = mcPrefixedSessions.has(sessionID) ? '[MC] ' : '';
117+
const resetTitle = `${mcPrefix}${state.originalTitle}`;
88118
titleState.delete(sessionID);
89119

90120
try {
91121
await client.session.update({
92122
path: { id: sessionID },
93-
body: { title: originalTitle },
123+
body: { title: resetTitle },
94124
});
95125
} catch {
96126
// Fire-and-forget: don't block on title reset failures
@@ -101,11 +131,43 @@ export function hasAnnotation(sessionID: string): boolean {
101131
return titleState.has(sessionID);
102132
}
103133

134+
export async function ensureMCPrefix(client: Client, sessionID: string | undefined): Promise<void> {
135+
if (!sessionID || !sessionID.startsWith('ses')) return;
136+
if (mcPrefixedSessions.has(sessionID)) return;
137+
138+
mcPrefixedSessions.add(sessionID);
139+
140+
try {
141+
if (titleState.has(sessionID)) {
142+
const state = titleState.get(sessionID)!;
143+
const annotatedTitle = buildAnnotatedTitle(state, sessionID);
144+
await client.session.update({
145+
path: { id: sessionID },
146+
body: { title: annotatedTitle },
147+
});
148+
} else {
149+
const session = await client.session.get({ path: { id: sessionID } });
150+
const currentTitle = extractSessionTitle(session) ?? '';
151+
if (currentTitle.startsWith('[MC]')) return;
152+
153+
await client.session.update({
154+
path: { id: sessionID },
155+
body: { title: `[MC] ${currentTitle}` },
156+
});
157+
}
158+
} catch {
159+
}
160+
}
161+
104162
// Exposed for testing only
105163
export function _getTitleStateForTesting(): Map<string, SessionTitleState> {
106164
return titleState;
107165
}
108166

167+
export function _getMCPrefixedSessionsForTesting(): Set<string> {
168+
return mcPrefixedSessions;
169+
}
170+
109171
async function sendMessage(client: Client, sessionID: string, text: string, expectReply = false): Promise<void> {
110172
await client.session.prompt({
111173
path: { id: sessionID },
@@ -181,15 +243,11 @@ export function setupNotifications(options: SetupNotificationsOptions): void {
181243
// Fall back to raw session ID
182244
}
183245

184-
const titleAnnotationMap: Partial<Record<NotificationEvent, string>> = {
185-
complete: 'done',
186-
failed: 'failed',
187-
awaiting_input: 'needs input',
188-
question: 'has question',
189-
};
190-
const statusText = titleAnnotationMap[event];
191-
if (statusText) {
192-
await annotateSessionTitle(client, sessionID, job.name, statusText);
246+
const annotatedEvents: NotificationEvent[] = [
247+
'complete', 'failed', 'blocked', 'needs_review', 'awaiting_input', 'question',
248+
];
249+
if (annotatedEvents.includes(event)) {
250+
await annotateSessionTitle(client, sessionID, job.name, event);
193251
}
194252

195253
const toastMap: Partial<Record<NotificationEvent, { variant: 'info' | 'success' | 'warning' | 'error'; title: string }>> = {

src/index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Plugin } from '@opencode-ai/plugin';
22
import { getSharedMonitor, setSharedNotifyCallback, getSharedNotifyCallback, setSharedOrchestrator } from './lib/orchestrator-singleton';
33
import { getCompactionContext, getJobCompactionContext } from './hooks/compaction';
44
import { shouldShowAutoStatus, getAutoStatusMessage } from './hooks/auto-status';
5-
import { setupNotifications, hasAnnotation, resetSessionTitle } from './hooks/notifications';
5+
import { setupNotifications, hasAnnotation, resetSessionTitle, isTitleResetSuppressed, ensureMCPrefix } from './hooks/notifications';
66
import { registerCommands, createCommandHandler } from './commands';
77
import { isTmuxAvailable } from './lib/tmux';
88
import { loadPlan } from './lib/plan-state';
@@ -222,23 +222,30 @@ export const MissionControl: Plugin = async ({ client }) => {
222222
'command.execute.before': (input: { command: string; sessionID: string; arguments: string }, output: { parts: unknown[] }) => {
223223
if (isValidSessionID(input.sessionID)) {
224224
activeSessionID = input.sessionID;
225-
if (hasAnnotation(input.sessionID)) {
225+
if (hasAnnotation(input.sessionID) && !isTitleResetSuppressed(input.sessionID)) {
226226
resetSessionTitle(client, input.sessionID).catch(() => {});
227227
}
228228
}
229229
return createCommandHandler(client)(input, output);
230230
},
231-
'tool.execute.before': async (input: { sessionID?: string; [key: string]: unknown }) => {
231+
'tool.execute.before': async (input: { tool: string; sessionID: string; callID: string }) => {
232232
if (input.sessionID && isValidSessionID(input.sessionID)) {
233233
activeSessionID = input.sessionID;
234-
if (hasAnnotation(input.sessionID)) {
234+
if (hasAnnotation(input.sessionID) && !isTitleResetSuppressed(input.sessionID)) {
235235
resetSessionTitle(client, input.sessionID).catch(() => {});
236236
}
237237
}
238+
if (input.tool?.startsWith('mc_')) {
239+
const sessionID = input.sessionID ?? await getActiveSessionID();
240+
ensureMCPrefix(client, sessionID).catch(() => {});
241+
}
238242
},
239243
'chat.message': async (input) => {
240244
if (input.sessionID && isValidSessionID(input.sessionID)) {
241245
activeSessionID = input.sessionID;
246+
if (hasAnnotation(input.sessionID) && !isTitleResetSuppressed(input.sessionID)) {
247+
resetSessionTitle(client, input.sessionID).catch(() => {});
248+
}
242249
}
243250
if (input.model) {
244251
setCurrentModel(input.model, input.sessionID);

tests/hooks/notifications.test.ts

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
annotateSessionTitle,
55
resetSessionTitle,
66
hasAnnotation,
7+
ensureMCPrefix,
78
_getTitleStateForTesting,
9+
_getMCPrefixedSessionsForTesting,
810
} from '../../src/hooks/notifications';
911
import type { Job } from '../../src/lib/job-state';
1012

@@ -29,6 +31,7 @@ describe('notifications hook', () => {
2931

3032
beforeEach(() => {
3133
_getTitleStateForTesting().clear();
34+
_getMCPrefixedSessionsForTesting().clear();
3235

3336
mockMonitor = {
3437
handlers: new Map(),
@@ -403,7 +406,7 @@ describe('notifications hook', () => {
403406

404407
expect(mockClient.session.update).toHaveBeenCalledWith({
405408
path: { id: 'ses_launcher' },
406-
body: { title: 'feature-auth done' },
409+
body: { title: '✅ Original Title' },
407410
});
408411
});
409412

@@ -435,7 +438,7 @@ describe('notifications hook', () => {
435438

436439
expect(mockClient.session.update).toHaveBeenCalledWith({
437440
path: { id: 'ses_launcher' },
438-
body: { title: 'fix-bug failed' },
441+
body: { title: '❌ Original Title' },
439442
});
440443
});
441444

@@ -466,7 +469,7 @@ describe('notifications hook', () => {
466469

467470
expect(mockClient.session.update).toHaveBeenCalledWith({
468471
path: { id: 'ses_launcher' },
469-
body: { title: 'setup-db needs input' },
472+
body: { title: '❓ Original Title' },
470473
});
471474
});
472475

@@ -518,11 +521,11 @@ describe('notifications hook', () => {
518521
const lastCall = calls[calls.length - 1][0];
519522
expect(lastCall).toEqual({
520523
path: { id: 'ses_launcher' },
521-
body: { title: '2 jobs need attention' },
524+
body: { title: '🔔 Original Title' },
522525
});
523526
});
524527

525-
it('should not annotate title for blocked events', async () => {
528+
it('should annotate title for blocked events', async () => {
526529
setupNotifications({
527530
client: mockClient as any,
528531
monitor: mockMonitor as any,
@@ -547,7 +550,10 @@ describe('notifications hook', () => {
547550
mockMonitor.handlers.get('blocked')![0](job);
548551
await new Promise((resolve) => setTimeout(resolve, 50));
549552

550-
expect(mockClient.session.update).not.toHaveBeenCalled();
553+
expect(mockClient.session.update).toHaveBeenCalledWith({
554+
path: { id: 'ses_launcher' },
555+
body: { title: '🚧 Original Title' },
556+
});
551557
});
552558

553559
it('should not annotate title when sessionID is undefined', async () => {
@@ -626,4 +632,105 @@ describe('notifications hook', () => {
626632
expect(hasAnnotation('ses_err')).toBe(false);
627633
});
628634
});
635+
636+
describe('[MC] prefix', () => {
637+
it('should prefix session title with [MC] on first call', async () => {
638+
await ensureMCPrefix(mockClient as any, 'ses_mc1');
639+
640+
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: 'ses_mc1' } });
641+
expect(mockClient.session.update).toHaveBeenCalledWith({
642+
path: { id: 'ses_mc1' },
643+
body: { title: '[MC] Original Title' },
644+
});
645+
});
646+
647+
it('should be idempotent — second call is a no-op', async () => {
648+
await ensureMCPrefix(mockClient as any, 'ses_mc2');
649+
await ensureMCPrefix(mockClient as any, 'ses_mc2');
650+
651+
expect(mockClient.session.update).toHaveBeenCalledTimes(1);
652+
});
653+
654+
it('should not prefix when sessionID is undefined', async () => {
655+
await ensureMCPrefix(mockClient as any, undefined);
656+
657+
expect(mockClient.session.get).not.toHaveBeenCalled();
658+
expect(mockClient.session.update).not.toHaveBeenCalled();
659+
});
660+
661+
it('should not prefix when sessionID is invalid', async () => {
662+
await ensureMCPrefix(mockClient as any, 'not-a-session');
663+
664+
expect(mockClient.session.get).not.toHaveBeenCalled();
665+
expect(mockClient.session.update).not.toHaveBeenCalled();
666+
});
667+
668+
it('should not double-prefix when title already starts with [MC]', async () => {
669+
mockClient.session.get.mockResolvedValueOnce({ data: { title: '[MC] Already Prefixed' } });
670+
await ensureMCPrefix(mockClient as any, 'ses_mc3');
671+
672+
expect(mockClient.session.update).not.toHaveBeenCalled();
673+
});
674+
675+
it('should include [MC] in annotated titles when session is prefixed', async () => {
676+
await ensureMCPrefix(mockClient as any, 'ses_mc4');
677+
mockClient.session.update.mockClear();
678+
679+
await annotateSessionTitle(mockClient as any, 'ses_mc4', 'my-job', 'complete');
680+
681+
expect(mockClient.session.update).toHaveBeenCalledWith({
682+
path: { id: 'ses_mc4' },
683+
body: { title: '[MC] \u2705 Original Title' },
684+
});
685+
});
686+
687+
it('should include [MC] in multi-annotation titles', async () => {
688+
await ensureMCPrefix(mockClient as any, 'ses_mc5');
689+
mockClient.session.update.mockClear();
690+
691+
await annotateSessionTitle(mockClient as any, 'ses_mc5', 'job-a', 'complete');
692+
await annotateSessionTitle(mockClient as any, 'ses_mc5', 'job-b', 'failed');
693+
694+
const calls = mockClient.session.update.mock.calls;
695+
const lastCall = calls[calls.length - 1][0];
696+
expect(lastCall).toEqual({
697+
path: { id: 'ses_mc5' },
698+
body: { title: '[MC] \uD83D\uDD14 Original Title' },
699+
});
700+
});
701+
702+
it('should preserve [MC] prefix on title reset', async () => {
703+
await ensureMCPrefix(mockClient as any, 'ses_mc6');
704+
await annotateSessionTitle(mockClient as any, 'ses_mc6', 'my-job', 'complete');
705+
mockClient.session.update.mockClear();
706+
707+
await resetSessionTitle(mockClient as any, 'ses_mc6');
708+
709+
expect(mockClient.session.update).toHaveBeenCalledWith({
710+
path: { id: 'ses_mc6' },
711+
body: { title: '[MC] Original Title' },
712+
});
713+
});
714+
715+
it('should re-render with [MC] when annotations already exist', async () => {
716+
await annotateSessionTitle(mockClient as any, 'ses_mc7', 'my-job', 'failed');
717+
mockClient.session.update.mockClear();
718+
719+
await ensureMCPrefix(mockClient as any, 'ses_mc7');
720+
721+
expect(mockClient.session.update).toHaveBeenCalledWith({
722+
path: { id: 'ses_mc7' },
723+
body: { title: '[MC] \u274C Original Title' },
724+
});
725+
});
726+
727+
it('should strip [MC] from fetched title when storing originalTitle', async () => {
728+
mockClient.session.get.mockResolvedValue({ data: { title: '[MC] My Session' } });
729+
730+
await annotateSessionTitle(mockClient as any, 'ses_mc8', 'my-job', 'complete');
731+
732+
const state = _getTitleStateForTesting().get('ses_mc8');
733+
expect(state?.originalTitle).toBe('My Session');
734+
});
735+
});
629736
});

tests/plugin-init.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('plugin initialization', () => {
122122
});
123123

124124
describe('command center context', () => {
125-
it('tool object has all 18 tools', async () => {
125+
it('tool object has all 19 tools', async () => {
126126
mockState.isManaged = false;
127127
vi.spyOn(worktree, 'isInManagedWorktree').mockResolvedValue({ isManaged: false });
128128

0 commit comments

Comments
 (0)