From 43f58420297965b986334206780e01ad7b393636 Mon Sep 17 00:00:00 2001 From: Brendon Jewell Date: Sun, 9 Aug 2026 15:53:05 -0400 Subject: [PATCH 1/2] docs(canvas): design linked agent notifications --- .../2026-08-09-agent-notify-control-design.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-agent-notify-control-design.md diff --git a/docs/superpowers/specs/2026-08-09-agent-notify-control-design.md b/docs/superpowers/specs/2026-08-09-agent-notify-control-design.md new file mode 100644 index 000000000..163cc807c --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-agent-notify-control-design.md @@ -0,0 +1,24 @@ +# Agent Notify Canvas Control + +## Goal + +Let a canvas-control-capable agent wake a context-linked agent after updating an external coordination channel, without approving arbitrary terminal input. + +## Command + +`nodeterm notify --node ` sends a fixed Nodeterm-authored prompt to the target. The command accepts no message text. + +## Trust boundary + +- Disabled by default and enabled in Settings > Notifications. +- Source must pass the existing canvas-control authorization check. +- Target must be a context-link-capable agent in the active project. +- Source and target must have a persisted context link. +- Each source-target pair is limited to one notification every 10 seconds. +- `write` and `close` retain their confirmation dialogs. + +## Prompt + +The target receives: `[nodeterm] A linked agent updated shared coordination context. Check your configured inbox before continuing.` + +The app owns the entire prompt so the source cannot inject instructions through command arguments. From 8d3b00b36a39a3bdeaa274da8236e4f5eba6ff29 Mon Sep 17 00:00:00 2001 From: Brendon Jewell Date: Sun, 9 Aug 2026 15:53:05 -0400 Subject: [PATCH 2/2] feat(canvas): add linked agent inbox notifications --- src/main/canvas-control-core.test.ts | 14 +++++- src/main/canvas-control-core.ts | 6 +++ src/renderer/canvas/Canvas.tsx | 47 +++++++++++++++++++ .../sections/NotificationsSection.tsx | 18 +++++++ src/shared/types.ts | 3 ++ 5 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/main/canvas-control-core.test.ts b/src/main/canvas-control-core.test.ts index d693f118e..a4f026920 100644 --- a/src/main/canvas-control-core.test.ts +++ b/src/main/canvas-control-core.test.ts @@ -28,6 +28,18 @@ describe('parseControlRequest', () => { }) }) + it('requires a target for notify and does not accept message text', () => { + expect(parseControlRequest('notify', {})).toEqual({ error: 'notify requires --node ' }) + expect(parseControlRequest('notify', { node: 'n1' })).toEqual({ + verb: 'notify', + args: { node: 'n1' } + }) + expect(parseControlRequest('notify', { node: 'n1', text: 'custom prompt' })).toEqual({ + error: 'notify does not accept --text' + }) + expect(isDestructiveVerb('notify')).toBe(false) + }) + it('requires a source for show verbs', () => { expect(parseControlRequest('show-video', {})).toEqual({ error: 'show-video requires --path' }) expect(parseControlRequest('show-web', {})).toEqual({ @@ -177,7 +189,7 @@ describe('parseControlRequest', () => { it('instructions cover the verb set and the confirm caveat', () => { const body = buildCanvasControlInstructions('/tmp/nodeterm.sh') - for (const verb of ['list', 'open-agent', 'spawn-team', 'group', 'ungroup', 'move', 'arrange', 'rename', 'write', 'close', 'board', 'assign']) { + for (const verb of ['list', 'open-agent', 'spawn-team', 'group', 'ungroup', 'move', 'arrange', 'rename', 'notify', 'write', 'close', 'board', 'assign']) { expect(body).toContain(verb) } expect(body.toLowerCase()).toContain('confirm') diff --git a/src/main/canvas-control-core.ts b/src/main/canvas-control-core.ts index 0b3b5c61f..241378d89 100644 --- a/src/main/canvas-control-core.ts +++ b/src/main/canvas-control-core.ts @@ -23,6 +23,7 @@ export type ControlVerb = | 'close-worktree' | 'branch' | 'rename' + | 'notify' | 'write' | 'close' | 'board' @@ -54,6 +55,7 @@ const VERBS: ControlVerb[] = [ 'close-worktree', 'branch', 'rename', + 'notify', 'write', 'close', 'board', @@ -74,6 +76,8 @@ export function parseControlRequest( if (!VERBS.includes(verb as ControlVerb)) return { error: `Unknown verb: ${verb}` } const v = verb as ControlVerb if (v === 'close' && !args.node) return { error: 'close requires --node ' } + if (v === 'notify' && !args.node) return { error: 'notify requires --node ' } + if (v === 'notify' && args.text) return { error: 'notify does not accept --text' } if (v === 'write' && !args.node) return { error: 'write requires --node ' } if (v === 'write' && !args.text) return { error: 'write requires --text' } if ((v === 'show-image' || v === 'show-video') && !args.path) { @@ -176,6 +180,8 @@ export function buildCanvasControlInstructions(shimPath: string): string { ' the user to confirm deletion.', '- `branch --node ` — branch a Claude node\'s conversation (Claude nodes only).', '- `rename --node --title "New Name"` — rename any node (terminals, groups, stickies…).', + '- `notify --node ` — tell a context-linked agent to check its configured coordination inbox.', + ' This fixed, rate-limited prompt requires the opt-in Settings toggle; it cannot carry arbitrary text.', '- `write --node --text "..."` / `close --node ` — type into / close a node.', ' Both ask the user to confirm a dialog and may be denied.', '- `board` — the project\'s kanban board: every column (id + title) and the session cards in each,', diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 27dc8739f..84d41d67e 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -657,6 +657,7 @@ export function Canvas() { const [controlEdges, setControlEdges] = useState([]) const controlEdgesRef = useRef([]) controlEdgesRef.current = controlEdges + const agentNotifyAtRef = useRef(new Map()) const [dirty, setDirty] = useState(false) // Bumped only when a save finished with `dirty` still set (an edit raced it). It exists purely to // give the debounced-autosave effect a dependency that CHANGES in that case — `dirty` stays true @@ -6555,6 +6556,52 @@ export function Canvas() { }) return } + case 'notify': { + const targetId = args.node ?? '' + if (!useSettings.getState().settings.agentInboxNotifications) { + reply({ ok: false, error: 'linked agent inbox notifications are disabled in Settings' }) + return + } + const target = nodesRef.current.find((node) => node.id === targetId) + const targetAgent = target?.data.agentId as AgentId | undefined + if (!target || target.type !== 'terminal' || !targetAgent || !canContextLink(targetAgent)) { + reply({ ok: false, error: `notify: ${targetId} is not a context-link-capable agent` }) + return + } + const linked = linkEdgesRef.current.some( + (edge) => + (edge.source === sourceNodeId && edge.target === targetId) || + (edge.source === targetId && edge.target === sourceNodeId) + ) + if (!linked) { + reply({ ok: false, error: `notify: ${targetId} is not context-linked to the source` }) + return + } + const throttleKey = `${sourceNodeId}:${targetId}` + const now = Date.now() + const lastSentAt = agentNotifyAtRef.current.get(throttleKey) ?? 0 + if (now - lastSentAt < 10_000) { + reply({ ok: false, error: 'notify: rate limited; wait 10 seconds between notifications' }) + return + } + try { + const ok = await api.pty.sendText( + targetId, + '[nodeterm] A linked agent updated shared coordination context. Check your configured inbox before continuing.' + ) + if (ok) { + agentNotifyAtRef.current.set(throttleKey, now) + console.info('[canvas-control] linked agent inbox notification', { + sourceNodeId, + targetNodeId: targetId + }) + } + reply({ ok, message: ok ? 'notified' : 'failed', error: ok ? undefined : 'sendText failed' }) + } catch (error) { + reply({ ok: false, error: String(error) }) + } + return + } case 'close': { if (!args.node) { reply({ ok: false, error: 'close requires --node' }) diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index de8d5201b..2d585bc8e 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -12,6 +12,10 @@ const ROWS = { title: 'Notify when a turn finishes in the background', keywords: ['notify', 'notification', 'claude', 'background', 'turn', 'done'] }, + agentInbox: { + title: 'Allow linked agents to signal inbox updates', + keywords: ['agent', 'linked', 'inbox', 'coordination', 'notify', 'automation'] + }, sound: { title: 'Play a sound when a turn finishes or needs you', keywords: ['sound', 'audio', 'sfx', 'effect', 'chime', 'beep', 'retro', '8-bit', 'chiptune', 'volume', 'mute', 'finished', 'needs you'] @@ -25,6 +29,7 @@ const ENTRIES = Object.values(ROWS) export function NotificationsSection({ isActive }: { isActive: boolean }): React.JSX.Element { const notifyOnClaudeDone = useSettings((s) => s.settings.notifyOnClaudeDone) + const agentInboxNotifications = useSettings((s) => s.settings.agentInboxNotifications) const soundEffects = useSettings((s) => s.settings.soundEffects) const soundVolume = useSettings((s) => s.settings.soundVolume) const mobilePushEnabled = useSettings((s) => s.settings.mobilePushEnabled) @@ -79,6 +84,19 @@ export function NotificationsSection({ isActive }: { isActive: boolean }): React )} + + update({ agentInboxNotifications: on })} + /> + } + /> +