Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/superpowers/specs/2026-08-09-agent-notify-control-design.md
Original file line number Diff line number Diff line change
@@ -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 <id>` 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.
14 changes: 13 additions & 1 deletion src/main/canvas-control-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>' })
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({
Expand Down Expand Up @@ -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')
Expand Down
6 changes: 6 additions & 0 deletions src/main/canvas-control-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type ControlVerb =
| 'close-worktree'
| 'branch'
| 'rename'
| 'notify'
| 'write'
| 'close'
| 'board'
Expand Down Expand Up @@ -55,6 +56,7 @@ const VERBS: ControlVerb[] = [
'close-worktree',
'branch',
'rename',
'notify',
'write',
'close',
'board',
Expand All @@ -75,6 +77,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 <id>' }
if (v === 'notify' && !args.node) return { error: 'notify requires --node <id>' }
if (v === 'notify' && args.text) return { error: 'notify does not accept --text' }
if (v === 'write' && !args.node) return { error: 'write requires --node <id>' }
if (v === 'write' && !args.text) return { error: 'write requires --text' }
if ((v === 'show-image' || v === 'show-video') && !args.path) {
Expand Down Expand Up @@ -177,6 +181,8 @@ export function buildCanvasControlInstructions(shimPath: string): string {
' the user to confirm deletion.',
'- `branch --node <id>` — branch a Claude node\'s conversation (Claude nodes only).',
'- `rename --node <id> --title "New Name"` — rename any node (terminals, groups, stickies…).',
'- `notify --node <id>` — 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 <id> --text "..."` / `close --node <id>` — 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,',
Expand Down
47 changes: 47 additions & 0 deletions src/renderer/canvas/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,7 @@ export function Canvas() {
const [controlEdges, setControlEdges] = useState<Edge[]>([])
const controlEdgesRef = useRef<Edge[]>([])
controlEdgesRef.current = controlEdges
const agentNotifyAtRef = useRef(new Map<string, number>())
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
Expand Down Expand Up @@ -6983,6 +6984,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' })
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/components/settings/sections/NotificationsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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)
Expand Down Expand Up @@ -79,6 +84,19 @@ export function NotificationsSection({ isActive }: { isActive: boolean }): React
</div>
)}
</SearchableRow>
<SearchableRow {...ROWS.agentInbox}>
<FieldRow
label="Allow linked agents to signal inbox updates"
description="Lets a context-linked agent send a fixed, rate-limited prompt to check your configured coordination inbox. Arbitrary terminal writes still require approval."
control={
<Switch
checked={agentInboxNotifications}
ariaLabel="Linked agent inbox notifications"
onChange={(on) => update({ agentInboxNotifications: on })}
/>
}
/>
</SearchableRow>
<SearchableRow {...ROWS.sound}>
<FieldRow
label="Play a sound when a turn finishes or needs you"
Expand Down
3 changes: 3 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,8 @@ export interface Settings {
seenOnboarding: boolean
/** Notify (OS notification) when a Claude Code turn finishes while the app is in the background. */
notifyOnClaudeDone: boolean
/** Allow linked agents to send one another Nodeterm's fixed inbox-check prompt. Default off. */
agentInboxNotifications: boolean
/** Periodically `git fetch` while the Source Control panel is open, so ahead/behind stays
* accurate (remote/SSH projects fetch on the remote). */
gitAutoFetch: boolean
Expand Down Expand Up @@ -1098,6 +1100,7 @@ export const DEFAULT_SETTINGS: Settings = {
seenShortcuts: false,
seenOnboarding: false,
notifyOnClaudeDone: true,
agentInboxNotifications: false,
gitAutoFetch: true,
notifyConsentAsked: false,
soundEffects: true,
Expand Down