Skip to content
Merged
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
54 changes: 52 additions & 2 deletions core/main/agentPoll.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { AGENT_POLL_MAX, AGENT_POLL_MIN, createAgentPoll, type AgentPollDeps } from './agentPoll'
import { AGENT_FAIL_MAX, AGENT_FORCE_GAP, AGENT_POLL_MAX, AGENT_POLL_MIN, createAgentPoll, type AgentPollDeps } from './agentPoll'

/**
* The poll's three rules, checked without a process list: ask only when a
Expand Down Expand Up @@ -47,9 +47,10 @@ describe('the agent poll', () => {
w.set({ out: '100 1\n200 100 claude\n', now: 1 }) // claude starts, silently, inside the backoff
poll()
expect(w.sent).toEqual([['t1', false, null]])
w.set({ now: AGENT_FORCE_GAP })
poll(true)
expect(w.sent).toEqual([['t1', false, null], ['t1', true, 'claude']])
w.set({ out: '100 1\n', now: 2 }) // and quits
w.set({ out: '100 1\n', now: 2 * AGENT_FORCE_GAP }) // and quits
poll(true)
expect(w.sent.at(-1)).toEqual(['t1', false, null])
const before = w.sent.length
Expand Down Expand Up @@ -140,10 +141,59 @@ describe('the agent poll', () => {
// Same tick count, but there is still no answer: it asks again.
w.set({ out: `100 1\n200 100 node ${CLAUDE}\n` })
poll()
// (Inside the failure's backoff it waits; past it, it asks again.)
expect(w.asked()).toBe(1)
w.set({ now: 2 * AGENT_POLL_MIN })
poll()
expect(w.asked()).toBe(2)
expect(w.sent).toEqual([['t1', true, 'claude']])
})

// Perf review 2026-10-16: a query that always failed launched PowerShell
// every 2.5 s for the app's life, and a Claude the poll cannot see (WSL,
// SSH) had every title frame force a new one.
it('a failing query backs off, doubling to a minute, forced looks included, and says so once', () => {
const w = world()
let failing = 0
const poll = createAgentPoll(w.send, { ...w.deps, failing: () => (failing += 1) })
w.set({ out: null })
let t = 0
const times: number[] = []
for (let i = 0; i < 400; i += 1) {
t += 500
w.set({ now: t, ticks: i })
const before = w.asked()
poll(i % 2 === 0)
if (w.asked() > before) times.push(t)
}
const gaps = times.slice(1).map((x, i) => x - times[i])
expect(gaps.slice(0, 4)).toEqual([5000, 10000, 20000, 40000])
expect(Math.max(...gaps)).toBe(AGENT_FAIL_MAX)
expect(failing).toBe(1)
// An answer resets it: the next look comes at the usual pace.
w.set({ out: '100 1\n', now: t + AGENT_FAIL_MAX })
poll()
w.set({ now: t + AGENT_FAIL_MAX + AGENT_POLL_MIN, ticks: 999 })
const before = w.asked()
poll()
expect(w.asked()).toBe(before + 1)
})

it('a forced look waits for the gap since the last query, then looks', () => {
const w = world()
const poll = createAgentPoll(w.send, w.deps)
poll()
expect(w.asked()).toBe(1)
for (let i = 1; i < 10; i += 1) {
w.set({ now: i * 400 })
poll(true)
}
expect(w.asked()).toBe(1)
w.set({ now: AGENT_FORCE_GAP })
poll(true)
expect(w.asked()).toBe(2)
})

it('forgets a session that ended, so its id starts clean', () => {
const w = world()
const poll = createAgentPoll(w.send, w.deps)
Expand Down
36 changes: 34 additions & 2 deletions core/main/agentPoll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@ import { execFile } from 'child_process'
import type { DetectedAgent } from '../shared/types'
import { parseProcLines, treeAgentKind } from './agentDetect'
import { livePids, ptyOutputTicks } from './terminal'
import { diagMain } from './diagLog'

/* ------------------------------------------------------------------ *
* The agent poll's shape. See the poll itself for why it is like this.
* ------------------------------------------------------------------ */
export const AGENT_POLL_MIN = 2500
export const AGENT_POLL_MAX = 20000
/**
* A FORCED LOOK WAITS THIS LONG after the last query started, and a query
* that FAILS backs off doubling up to the ceiling below, forced looks
* included (perf review 2026-10-16). Before, a title or hook signal forced a
* PowerShell + CIM query on every frame of a Claude the poll cannot see
* (Claude under WSL or SSH: its process is not in the Windows tree, so the
* poll never reports it and the renderer asks again on the next frame), half
* to a whole core while it worked; and a query that always fails (WMI broken,
* AppLocker, Constrained Language) launched PowerShell every 2.5 s for the
* app's whole life.
*/
export const AGENT_FORCE_GAP = 5000
export const AGENT_FAIL_MAX = 60000
/**
* "pid ppid" for everything, plus the command line only where a cheap word
* match hits. The full dump with every command line was megabytes; this is a
Expand Down Expand Up @@ -42,13 +56,17 @@ export interface AgentPollDeps {
ticks: () => number
query: (done: (stdout: string | null) => void) => void
now: () => number
/** Said once when the query has failed three times in a row: why agent
* marks are missing, for the diagnostics log. */
failing?: () => void
}

const REAL: AgentPollDeps = {
pids: livePids,
ticks: ptyOutputTicks,
query: queryProcesses,
now: () => Date.now()
now: () => Date.now(),
failing: () => diagMain().write('main', 'agent-poll-failing', { note: 'the process query keeps failing: agent marks rely on titles and hooks alone' })
}

/**
Expand Down Expand Up @@ -90,11 +108,20 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): Ag
// started again between two looks. Held until a look STARTS, so a look
// already in flight, taken before the ask, does not spend it.
const asked = new Set<string>()
/** When the last query started, and the failures in a row since one answered. */
let lastStart = Number.NEGATIVE_INFINITY
let fails = 0
let failNext = 0
// `force` skips the two waits below, never the answer (#73): a look the
// renderer asked for sends only what CHANGED, like any other.
const poll = (force = false): void => {
const poll = (asForced = false): void => {
const pids = deps.pids()
if (!pids.length || agentBusy) return
const startAt = deps.now()
// A failing query backs off for everyone, forced looks too.
if (fails && startAt < failNext) return
// A forced look inside the gap is an ordinary one: its waits apply.
const force = asForced && startAt - lastStart >= AGENT_FORCE_GAP
const ticks = deps.ticks()
const quiet = ticks === agentSeenTicks
const known = pids.every((s) => agentState.has(s.id) && !asked.has(s.id))
Expand All @@ -107,6 +134,7 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): Ag
const now = deps.now()
if (known && now < agentNext && !force) return
agentBusy = true
lastStart = startAt
const asking = new Set(asked)
asked.clear()
deps.query((stdout) => {
Expand All @@ -118,8 +146,12 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): Ag
const rows = stdout ? parseProcLines(stdout) : []
if (!rows.length) {
asking.forEach((id) => asked.add(id))
fails += 1
failNext = deps.now() + Math.min(AGENT_FAIL_MAX, AGENT_POLL_MIN * 2 ** fails)
if (fails === 3) deps.failing?.()
return
}
fails = 0
agentSeenTicks = ticks
let changed = false
for (const { id, pid } of deps.pids()) {
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism-term-core",
"version": "0.41.0",
"version": "0.41.1",
"description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.",
"license": "MIT",
"private": true,
Expand Down
32 changes: 28 additions & 4 deletions core/renderer/lib/useAgentIndicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
*/
const [moved, setMoved] = useState(0)
const movedAt = useRef(0)
// Whether a look by the pointer could take anything down: only a Finished
// or Failed mark on the tab in front (perf review 2026-10-16: every move
// over the unfocused window re-rendered App twice a second for nothing).
const frontMarked = useRef(false)
useEffect(() => {
frontMarked.current = !!activeId && (doneIds.has(activeId) || failedIds.has(activeId))
}, [activeId, doneIds, failedIds])
useEffect(() => {
const MOVE_LOOK_MS = 1000
let last = 0
Expand All @@ -111,6 +118,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
if (looking.current.focused) return
const now = Date.now()
movedAt.current = now
if (!frontMarked.current) return
// A look every half second at most: a moving pointer fires constantly.
if (now - last < MOVE_LOOK_MS / 2) return
last = now
Expand Down Expand Up @@ -156,6 +164,21 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
const titled = useRef(new Set<string>())
/** Sessions the process poll has said host an agent. */
const polled = useRef(new Set<string>())
/**
* ASK THE POLL TO LOOK, AT MOST ONCE IN 15 S PER TAB (perf review
* 2026-10-16). Every title frame and hook signal of an agent the poll has
* not reported asked for a look, and a Claude the poll can never see (under
* WSL or SSH) kept PowerShell queries running back to back while it worked.
* Main also holds forced looks 5 s apart (agentPoll).
*/
const lookAsked = useRef(new Map<string, number>())
const askLook = useCallback((id: string): void => {
if (polled.current.has(id)) return
const now = Date.now()
if (now - (lookAsked.current.get(id) ?? Number.NEGATIVE_INFINITY) < 15_000) return
lookAsked.current.set(id, now)
termApi().termAgentLook?.()
}, [])
/** The one clearing timer per fallback-scored session. */
const fallbackTimers = useRef(new Map<string, number>())
const stopFallback = useCallback((id: string): void => {
Expand Down Expand Up @@ -389,7 +412,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
// Present by its title alone: have the poll look now, so it sees the
// agent while it runs and can say when it leaves (#73). Only the poll
// ever takes a title's claim back.
if (!polled.current.has(id)) termApi().termAgentLook?.()
askLook(id)
if (!titled.current.has(id)) {
titled.current.add(id)
outputRuns.current.delete(id)
Expand Down Expand Up @@ -424,7 +447,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
return next
})
}),
[stopFallback, checkQuestion, applyHook, stopAnswerCheck]
[stopFallback, checkQuestion, applyHook, stopAnswerCheck, askLook]
)

/**
Expand All @@ -437,7 +460,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
() =>
onAgentSignal((id, signal) => {
logAgentHook(id, signal.state, signal.kind)
if (!polled.current.has(id)) termApi().termAgentLook?.()
askLook(id)
if (!hooked.current.has(id)) {
outputRuns.current.delete(id)
stopFallback(id)
Expand All @@ -449,7 +472,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
setAgentIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id)))
applyHook(id, signal)
}),
[stopFallback, applyHook]
[stopFallback, applyHook, askLook]
)

/**
Expand Down Expand Up @@ -557,6 +580,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
outputRuns.current.delete(id)
titled.current.delete(id)
polled.current.delete(id)
lookAsked.current.delete(id)
agentKinds.current.delete(id)
forgetAgentTitle(id)
stopFallback(id)
Expand Down
2 changes: 1 addition & 1 deletion core/shared/termVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
* so a core release that bumps `core/package.json` (or xterm) without this
* fails `npm test` (core/README.md, "Releasing the core").
*/
export const TERM_CORE_VERSION = '0.41.0'
export const TERM_CORE_VERSION = '0.41.1'
export const XTERM_VERSION = '6.0.0'
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prism-terminal",
"productName": "Prism Terminal",
"version": "0.55.0",
"version": "0.55.1",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
Loading