mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
feat(approvals): let a watching session keep quiet, and fix the tui gate
The badge alone left the row in NEEDS YOU, which is the thing the issue was about. The fix is the alert that does not fire. An idle prompt from a session that is watching its own background work now opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to `notePrompt()`, which sets `acknowledgedAt` and records why in a new `acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", and the prompt itself stays pending, answerable and available as Read My Mind context. A wrong label therefore costs a card that does not blink, never an alert that was never created. Every surface follows from that. The broadcast carries the reason, so a live page declines to arm the tab alert and raises no desktop notification. The push is skipped, since a false alarm is hardest to ignore on a phone. A reloading page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And `classifySession()` now reads it too, which is a pre-existing bug fixed here: acknowledging on one device cleared the alert everywhere except `codeman tui`. It re-arms for free, because the next idle prompt supersedes the item and is built fresh. Only `idle` is eligible, so a dialog that blocks the agent still goes red whatever else it started. The label is pane-derived and therefore prompt-injectable, so it is now read from the last two rows of the screen only, with Claude's pattern anchored on the `·` its footer joins items with, ANSI-stripped and length-capped at the source. An agent that prints `· 1 monitor ·` into its own output finds no match. Verified on an isolated beta: a session that armed a monitor took its idle prompt acknowledged with no alert on any surface, wore the badge, and showed "quiet, watching 1 monitor" on its still-answerable card; the same session with the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts` pins both directions across all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f2cde2db7
commit
74884a20eb
@@ -207,10 +207,17 @@ const CLAUDE: CliEntry = {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
||||
// Claude prints what it started in the background on the footer row beneath its
|
||||
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are the
|
||||
// CLI's own words for each kind of background task, and group 1 is the one Codeman
|
||||
// badges the session with. Verified against a live 2.1.278 pane on 2026-09-21.
|
||||
watchingLine: String.raw`(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are
|
||||
// the CLI's own words for each kind of background task, and group 1 is the one
|
||||
// Codeman badges the session with. Verified against a live 2.1.278 pane on
|
||||
// 2026-09-21.
|
||||
// ⚠️ The leading `·` is an anchor, not decoration. This pattern runs over the foot
|
||||
// of the screen, which is the one part of it the AGENT does not write, and the
|
||||
// separator is what keeps it on the footer's own item list. An agent that could get
|
||||
// a bare `1 monitor` matched would silence its own idle alert by printing it. A
|
||||
// footer that ever carries the chip as its only item therefore reports no watching
|
||||
// rather than opening that door. See `watchingLabel()` in `session-activity.ts`.
|
||||
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||
},
|
||||
requiresMux: false,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
|
||||
+33
-16
@@ -21,6 +21,8 @@
|
||||
* in 12/12 windows and the four idle ones in 0/12.
|
||||
*/
|
||||
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
|
||||
/**
|
||||
* A gap longer than this ends a run of continuous output. Claude repaints at
|
||||
* least once a second while working, so this leaves generous headroom.
|
||||
@@ -95,13 +97,21 @@ export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: numb
|
||||
/**
|
||||
* How many lines at the foot of a pane capture may hold the background-work chip.
|
||||
*
|
||||
* Claude Code draws that chip on the last row of the screen, under its composer box
|
||||
* and under whatever status line the user configured, so five lines reach it with
|
||||
* room to spare. The ceiling is the point of the constant: the transcript above the
|
||||
* composer quotes arbitrary text, and a session that PRINTS the words "1 monitor"
|
||||
* must not be read as running one.
|
||||
* Claude Code draws that chip on the last row of the screen. The row above it is the
|
||||
* status line, which a user's own `statusLine` command writes, and two lines is what
|
||||
* covers the chip wherever a trailing blank or a one-line notice pushes it up by one.
|
||||
*
|
||||
* ⚠️ The ceiling is the security boundary, not a tidiness measure. The label is
|
||||
* PANE-DERIVED, so everything on that screen above the footer is text an agent wrote
|
||||
* itself, and an agent that printed `· 1 monitor ·` into its own output would silence
|
||||
* its own idle alert. Keep the window at the footer, keep each CLI's pattern anchored
|
||||
* on the separator its footer actually uses, and never widen this to a whole-pane
|
||||
* search.
|
||||
*/
|
||||
export const WATCHING_TAIL_LINES = 5;
|
||||
export const WATCHING_TAIL_LINES = 2;
|
||||
|
||||
/** Longest label a badge will carry. A footer chip is a handful of words. */
|
||||
export const MAX_WATCHING_LABEL_CHARS = 40;
|
||||
|
||||
/**
|
||||
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
|
||||
@@ -112,21 +122,28 @@ export const WATCHING_TAIL_LINES = 5;
|
||||
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
|
||||
* declares one, and the whole match stands in when it does not.
|
||||
*
|
||||
* Each candidate line is tested on its own, bottom row first, so a pattern can anchor
|
||||
* itself with `^` or `$` against a single row rather than against a joined block. The
|
||||
* answer is stripped of ANSI and capped, because it ends up on a badge and in an
|
||||
* approval card.
|
||||
*
|
||||
* @returns the label, or null when the pane shows no background work
|
||||
*/
|
||||
export function watchingLabel(paneText: string | null | undefined, pattern: RegExp): string | null {
|
||||
if (!paneText) return null;
|
||||
const lines = paneText
|
||||
const lines = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line !== '');
|
||||
if (lines.length === 0) return null;
|
||||
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a caller
|
||||
// reaching in from a test or a config reload might, and a stale lastIndex would make
|
||||
// the same screen match every other call.
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(lines.slice(-WATCHING_TAIL_LINES).join('\n'));
|
||||
if (!match) return null;
|
||||
const label = (match[1] ?? match[0]).trim();
|
||||
return label === '' ? null : label;
|
||||
for (const line of lines.slice(-WATCHING_TAIL_LINES).reverse()) {
|
||||
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
|
||||
// caller reaching in from a test or a config reload might, and a stale lastIndex
|
||||
// would make the same screen match every other call.
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(line);
|
||||
if (!match) continue;
|
||||
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
|
||||
if (label) return label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+17
-1
@@ -498,7 +498,21 @@ export class Session extends EventEmitter {
|
||||
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
||||
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
||||
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
||||
private _watching: string | null = null; // Background work the pane's own footer reports
|
||||
/**
|
||||
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
|
||||
*
|
||||
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
|
||||
* happened, so it goes stale exactly as that verdict does. The probe returns its
|
||||
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
|
||||
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
|
||||
*
|
||||
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
|
||||
* there, and nothing looks at the pane again until it produces output. That is
|
||||
* correct rather than merely tolerable, because the background work ending is itself
|
||||
* what wakes the agent and repaints the pane. Do not add a timer to keep this fresh;
|
||||
* it would spend a `capture-pane` per idle session per tick to learn nothing.
|
||||
*/
|
||||
private _watching: string | null = null;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
@@ -2743,6 +2757,8 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _readWatching(paneText: string | null): void {
|
||||
const pattern = this._watchingLinePattern();
|
||||
// Called only from the probe, and only with what a capture returned: `null` is
|
||||
// "the screen could not be read", which is not evidence that nothing is running.
|
||||
if (!pattern || paneText === null) return;
|
||||
this._watching = watchingLabel(paneText, pattern);
|
||||
}
|
||||
|
||||
+18
-2
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
|
||||
* outranks a stale `busy` status because the hook is the newer signal. An
|
||||
* errored session has no state of its own here and joins the waiting tier,
|
||||
* since it is equally something only a human can clear.
|
||||
*
|
||||
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
|
||||
* alert this prompt armed has been spent, either because somebody opened the
|
||||
* session on another device or because the inbox opened the item that way for a
|
||||
* session watching its own background work. The item itself stays pending and
|
||||
* answerable, so the row keeps carrying it and the approval card still renders;
|
||||
* it simply stops dragging the session into NEEDS YOU. The web has honoured
|
||||
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
|
||||
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
|
||||
* reading past it: acknowledging on a phone cleared the alert everywhere except
|
||||
* here. Only `idle` can be acknowledged, so a permission or question dialog is
|
||||
* unaffected by construction, and both are checked ahead of the flag anyway.
|
||||
*/
|
||||
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
||||
if (!isLiveRow(session)) return 'recent';
|
||||
if (approval) {
|
||||
if (approval.kind === 'permission') return 'blocked-permission';
|
||||
if (approval.kind === 'question') return 'blocked-question';
|
||||
return 'waiting';
|
||||
if (!approval.acknowledgedAt) return 'waiting';
|
||||
}
|
||||
if (session.status === 'error') return 'waiting';
|
||||
if (session.isWorking === true || session.status === 'busy') return 'working';
|
||||
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
|
||||
* turn's own start is the pane's last Enter.
|
||||
*/
|
||||
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
||||
if (approval) return approval.createdAt;
|
||||
// The prompt's own age measures the state only while the prompt is what put the
|
||||
// row in that state. An acknowledged item still rides along on a row that is
|
||||
// plainly idle or working, and dating such a row from it would report how long
|
||||
// ago the prompt arrived as though it were how long the session has been quiet.
|
||||
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
|
||||
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
||||
return session.lastActivityAt ?? session.createdAt ?? 0;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,15 @@ export interface ApprovalItem {
|
||||
* and reach the user's other devices. See `acknowledge()`.
|
||||
*/
|
||||
acknowledgedAt?: number;
|
||||
/**
|
||||
* Why the item arrived already acknowledged, for display only: the inbox
|
||||
* writes `watching 1 monitor` for a session that went quiet because work it
|
||||
* started itself is still running. A human acknowledgement leaves this unset,
|
||||
* so a card can say "quiet, watching 1 monitor" rather than implying somebody
|
||||
* looked. ⚠️ Pane-derived text, so it is bounded at the source and must not
|
||||
* reach the DOM as markup — see `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
acknowledgedReason?: string;
|
||||
/**
|
||||
* Present only when the frame parsed confidently. Gates which digits the
|
||||
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
|
||||
@@ -93,6 +102,12 @@ interface NotePromptArgs {
|
||||
toolSummary?: string;
|
||||
message?: string;
|
||||
cwd?: string;
|
||||
/**
|
||||
* What the session's pane says is still running in the background
|
||||
* (`Session.watching`, e.g. `1 monitor`). An idle prompt from such a session
|
||||
* opens ALREADY acknowledged: see `notePrompt()`.
|
||||
*/
|
||||
watching?: string | null;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
}
|
||||
@@ -217,6 +232,22 @@ export class ApprovalInbox {
|
||||
* Record a prompt for a session, superseding any previous item, and return
|
||||
* the new item. Captures context immediately and once more after a short
|
||||
* delay (see RECAPTURE_DELAY_MS).
|
||||
*
|
||||
* ⚠️ An idle prompt from a session that is WATCHING its own background work
|
||||
* opens already acknowledged (`args.watching`). Claude Code ends the turn
|
||||
* after arming a monitor or backgrounding a shell and then reports the pane
|
||||
* idle a minute later, so the alert that follows asks a human to look at a
|
||||
* session that wants nothing from them. Acknowledging is deliberately what
|
||||
* happens here rather than skipping the item: the prompt is real and stays
|
||||
* pending, answerable and available as Read My Mind context, and only the
|
||||
* alert it would have armed is spent. A wrong label therefore costs a card
|
||||
* that does not blink, never an alert that was never created.
|
||||
*
|
||||
* It re-arms by itself. The next idle prompt supersedes this item and builds
|
||||
* a fresh one, so once the background work ends and the session goes quiet
|
||||
* for an ordinary reason, that item carries no acknowledgement and alerts
|
||||
* normally. Only `idle` is eligible: a permission or question dialog blocks
|
||||
* the agent whatever else it started, so its alert must survive.
|
||||
*/
|
||||
notePrompt(args: NotePromptArgs): ApprovalItem {
|
||||
this.resolveForSession(args.sessionId, 'superseded');
|
||||
@@ -231,6 +262,10 @@ export class ApprovalInbox {
|
||||
message: args.message,
|
||||
cwd: args.cwd,
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
}
|
||||
this.applyCapture(item, args.capture);
|
||||
this.items.set(args.sessionId, item);
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
|
||||
@@ -4617,6 +4617,10 @@ class CodemanApp {
|
||||
// The word is duplicated from mobile-overview.js for the same reason the pill labels
|
||||
// above are: it is one word, and this file must render a complete row even when a
|
||||
// stale cached mobile-overview.js has arrived without it.
|
||||
// The visible text is that constant. The pane-derived label appears only in the
|
||||
// tooltip, where escapeHtml() (which escapes both quote characters) is what this file
|
||||
// already relies on for every untrusted string it puts in an attribute, and where the
|
||||
// source caps it at MAX_WATCHING_LABEL_CHARS before it ever gets here.
|
||||
if (row.watching) {
|
||||
const title = escapeHtml(`Still running in the background: ${row.watching}`);
|
||||
parts.push(`<span class="tab-pill tab-pill--watching" title="${title}">watching</span>`);
|
||||
@@ -5277,7 +5281,7 @@ class CodemanApp {
|
||||
const richMeta = this._sidebarRichMetaHTML(richRow);
|
||||
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
|
||||
const richData = richRow
|
||||
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
|
||||
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${richRow.watching}"`
|
||||
: '';
|
||||
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
|
||||
@@ -222,6 +222,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
|
||||
// The quiet reason is the only pane-derived string on a card, and it is the one
|
||||
// an agent could write itself (it prints its own footer row), so it reaches the
|
||||
// DOM as text and never as markup. The card leaves an empty span for it.
|
||||
for (const item of items) {
|
||||
if (!item.acknowledgedReason) continue;
|
||||
const card = list.querySelector(`[data-approval-id="${CSS.escape(item.id)}"]`);
|
||||
const slot = card && card.querySelector('.approval-quiet');
|
||||
if (slot) slot.textContent = 'quiet, ' + item.acknowledgedReason;
|
||||
}
|
||||
},
|
||||
|
||||
_approvalCardHtml(item) {
|
||||
@@ -260,6 +269,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
|
||||
`<span class="approval-age" data-i18n-skip>${age}</span>` +
|
||||
`</div>` +
|
||||
// Filled by renderApprovalsDrawer through textContent, never here: see the note
|
||||
// there. An item a human acknowledged carries no reason and gets no line.
|
||||
(item.acknowledgedReason ? `<div class="approval-quiet" data-i18n-skip></div>` : '') +
|
||||
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
|
||||
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
|
||||
`<div class="approval-actions">${actions}</div>` +
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// Hooks (Claude Code hook events)
|
||||
_onHookIdlePrompt(data) {
|
||||
// A prompt the server opened ALREADY acknowledged raises no alert here. Today that
|
||||
// means the session is watching work it started itself (`acknowledgedReason` reads
|
||||
// "watching 1 monitor"), so the pane is quiet because the agent is waiting for its
|
||||
// own monitor, not for you. The item still exists and still shows in the drawer;
|
||||
// only the tab alert and the desktop notification are declined. A page that reloads
|
||||
// instead of receiving this event reaches the same conclusion from `acknowledgedAt`
|
||||
// in seedApprovals (approvals-ui.js).
|
||||
if (data.acknowledgedReason) return;
|
||||
// Always track pending hook - alert will show when switching away from session
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'idle_prompt');
|
||||
|
||||
@@ -12382,6 +12382,17 @@ kbd {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
/* Why this card is not blinking at anyone: the session is watching work it
|
||||
started itself. Accent, like the watching badge on a session row, and never
|
||||
the red or yellow that mean a human is needed. */
|
||||
.approval-quiet {
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.approval-summary {
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
|
||||
@@ -176,6 +176,12 @@ export function registerHookEventRoutes(
|
||||
// identity beyond the shared per-instance secret, so a prompt claimed for a
|
||||
// session that can never show one must not create an answerable item).
|
||||
let approvalId: string | undefined;
|
||||
// Set when the item opened ALREADY acknowledged, which today means the session is
|
||||
// watching work it started itself. It rides the broadcast so a live page declines to
|
||||
// arm the alert (a reloading page learns the same thing from `acknowledgedAt` when it
|
||||
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
|
||||
// is worth even less on a phone than in a tab.
|
||||
let acknowledgedReason: string | undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
@@ -194,6 +200,10 @@ export function registerHookEventRoutes(
|
||||
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
|
||||
message: typeof safeData.message === 'string' ? safeData.message : undefined,
|
||||
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
|
||||
// What the pane says is still running in the background. An idle prompt from a
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
watching: session.watching,
|
||||
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||
capture: () => {
|
||||
@@ -203,6 +213,7 @@ export function registerHookEventRoutes(
|
||||
},
|
||||
});
|
||||
approvalId = item.id;
|
||||
acknowledgedReason = item.acknowledgedReason;
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
|
||||
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||
}
|
||||
@@ -213,6 +224,7 @@ export function registerHookEventRoutes(
|
||||
timestamp: Date.now(),
|
||||
...safeData,
|
||||
...(approvalId && { approvalId }),
|
||||
...(acknowledgedReason && { acknowledgedReason }),
|
||||
});
|
||||
// Full state ride-along, same shape as the working/idle handlers: the home
|
||||
// screens rank the blocked group on lastActivityAt, and without this a
|
||||
@@ -224,12 +236,17 @@ export function registerHookEventRoutes(
|
||||
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
|
||||
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
|
||||
// notification instead of offering buttons whose answer would be refused.
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
// Nothing to push for a prompt that opened acknowledged: the agent is waiting for its
|
||||
// own monitor or backgrounded shell, and a phone buzzing about it is the same false
|
||||
// alarm as the tab alert, delivered where it is hardest to ignore.
|
||||
if (!acknowledgedReason) {
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
|
||||
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||
|
||||
Reference in New Issue
Block a user