thinkpool-pair 0.7.34 → 0.7.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/account.mjs +6 -4
- package/bridge.mjs +51 -17
- package/launcher.mjs +42 -18
- package/package.json +1 -1
- package/update-gate.mjs +11 -3
package/account.mjs
CHANGED
|
@@ -208,7 +208,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
208
208
|
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Auto-serving your sessions from ${DEFAULT_DIR}\n (point one at another repo: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
209
209
|
|
|
210
210
|
const children = new Map() // room -> child process
|
|
211
|
-
const childIdle = new Map()
|
|
211
|
+
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
212
|
+
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
212
213
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
213
214
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
214
215
|
let pendingUpdate = null // newest published version once the poll sees it
|
|
@@ -282,13 +283,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
282
283
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
283
284
|
children.set(room, child)
|
|
284
285
|
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
|
286
|
+
childBetween.set(room, false)
|
|
285
287
|
childPeer.set(room, false)
|
|
286
288
|
child.on('message', (m) => {
|
|
287
289
|
if (!m) return
|
|
288
|
-
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
290
|
+
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
289
291
|
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
290
292
|
})
|
|
291
|
-
child.on('exit', () => { children.delete(room); childIdle.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
293
|
+
child.on('exit', () => { children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
292
294
|
// If an update is already pending, tell the freshly-served child immediately so
|
|
293
295
|
// its room shows the "update ready" chip without waiting for the next poll.
|
|
294
296
|
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
|
@@ -317,7 +319,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
317
319
|
// child idle (between turns) AND either a user clicked apply OR nobody's watching
|
|
318
320
|
// (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
|
|
319
321
|
const applyIfIdle = () => {
|
|
320
|
-
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childPeer, requested: applyRequested })) return
|
|
322
|
+
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
|
|
321
323
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
322
324
|
stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
|
|
323
325
|
}
|
package/bridge.mjs
CHANGED
|
@@ -164,7 +164,7 @@ if (argv[0] === 'provider') { const { runProvider } = await import('./provider.m
|
|
|
164
164
|
// so launchd / systemd / CI / pipes are byte-for-byte unchanged.
|
|
165
165
|
// Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
|
|
166
166
|
if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
167
|
-
const { runLauncher } = await import('./launcher.mjs')
|
|
167
|
+
const { runLauncher, detectState } = await import('./launcher.mjs')
|
|
168
168
|
const svc = await import('./service.mjs')
|
|
169
169
|
const BRIDGE = fileURLToPath(import.meta.url)
|
|
170
170
|
const io = {
|
|
@@ -186,9 +186,18 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
186
186
|
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
|
|
187
187
|
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
|
|
188
188
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
189
|
-
|
|
189
|
+
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
190
|
+
// menu so you can pick a model, switch back, or do something else. (It used to
|
|
191
|
+
// process.exit(0), which dumped you to the shell mid-setup.)
|
|
192
|
+
setProvider: async ({ kind, base, token, model }) => {
|
|
193
|
+
const { runProvider } = await import('./provider.mjs')
|
|
194
|
+
const args = kind === 'custom'
|
|
195
|
+
? ['custom', '--base', base, '--token', token, ...(model ? ['--model', model] : [])]
|
|
196
|
+
: ['anthropic']
|
|
197
|
+
await runProvider(args)
|
|
198
|
+
},
|
|
190
199
|
}
|
|
191
|
-
await runLauncher({ actions, io })
|
|
200
|
+
await runLauncher({ actions, io, refresh: detectState })
|
|
192
201
|
process.exit(0)
|
|
193
202
|
}
|
|
194
203
|
|
|
@@ -650,7 +659,13 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
650
659
|
if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
|
|
651
660
|
// Persist the permission mode alongside the transcript so a bridge restart
|
|
652
661
|
// restores the session in the SAME mode (a bypass room stays bypass on resume).
|
|
653
|
-
const
|
|
662
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
663
|
+
const persist = () => saveSession(room, id, sessionData())
|
|
664
|
+
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
665
|
+
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
666
|
+
// 1.5s saveSession debounce window) and on shutdown (so events since the last
|
|
667
|
+
// debounced write aren't lost). Contract #2: restart resumes, no lost messages.
|
|
668
|
+
entry.flush = () => flushSession(room, id, sessionData())
|
|
654
669
|
entry.session = startClaudeSession({
|
|
655
670
|
cwd: process.cwd(), model, resume, mode,
|
|
656
671
|
env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
@@ -708,6 +723,10 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
708
723
|
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
709
724
|
}),
|
|
710
725
|
})
|
|
726
|
+
// Brand-new session (no restored log): write its record now so a restart that lands
|
|
727
|
+
// before the first debounced save still restores this id instead of dropping it (the
|
|
728
|
+
// room would otherwise show a fresh empty terminal + strand the transcript).
|
|
729
|
+
if (!entry.log.length) entry.flush()
|
|
711
730
|
announce()
|
|
712
731
|
if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
|
|
713
732
|
return entry
|
|
@@ -1002,7 +1021,11 @@ channel
|
|
|
1002
1021
|
// only runs on a fresh start (guard above), so a network re-subscribe
|
|
1003
1022
|
// with live sessions won't re-spawn them.
|
|
1004
1023
|
if (wantStructured(startCmd)) {
|
|
1005
|
-
|
|
1024
|
+
// Restore EVERY saved rec — a freshly opened session (persisted on open) may
|
|
1025
|
+
// have an empty log + no sessionId yet and must still come back under its id.
|
|
1026
|
+
// Closed sessions are unlinked by deleteSession, so a file existing on disk ==
|
|
1027
|
+
// a session that was live at shutdown (never an explicitly closed one).
|
|
1028
|
+
const all = loadAll(room)
|
|
1006
1029
|
if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode })
|
|
1007
1030
|
// Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
|
|
1008
1031
|
// (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
|
|
@@ -1059,12 +1082,15 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1059
1082
|
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
1060
1083
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1061
1084
|
// pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
|
|
1062
|
-
|
|
1085
|
+
// Between turns = safe to restart per Contract #1 (no turn in flight, no bytes
|
|
1086
|
+
// mid-flush). Idle = that PLUS the quiet window — the extra conservatism only the
|
|
1087
|
+
// unattended auto-apply needs; an explicit click restarts on between-turns alone.
|
|
1088
|
+
const betweenTurns = () => {
|
|
1063
1089
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1064
|
-
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1065
1090
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1066
1091
|
return true
|
|
1067
1092
|
}
|
|
1093
|
+
const isIdle = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1068
1094
|
// Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
|
|
1069
1095
|
const isNewer = (latest, cur) => {
|
|
1070
1096
|
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -1083,11 +1109,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1083
1109
|
} catch { return null } finally { clearTimeout(to) }
|
|
1084
1110
|
}
|
|
1085
1111
|
const applyIfIdle = () => {
|
|
1086
|
-
if (!pendingUpdate
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
|
|
1112
|
+
if (!pendingUpdate) return
|
|
1113
|
+
// Explicit "apply" → go at the next between-turns gap (the click means the user
|
|
1114
|
+
// opted in; don't make them wait out the quiet window). Unattended (no click) →
|
|
1115
|
+
// require the full idle window AND nobody watching, so a headless box self-heals
|
|
1116
|
+
// without ever restarting under someone who's mid-session.
|
|
1117
|
+
if (applyRequested) { if (!betweenTurns()) return }
|
|
1118
|
+
else if (!isIdle() || webPeerPresent()) return
|
|
1091
1119
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
1092
1120
|
shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
|
|
1093
1121
|
}
|
|
@@ -1108,17 +1136,19 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1108
1136
|
// supervisor restart re-resolves @latest. Instead we report our idle state so
|
|
1109
1137
|
// the supervisor can pick a moment when EVERY child is idle to restart-update.
|
|
1110
1138
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1111
|
-
const
|
|
1139
|
+
const betweenTurns = () => {
|
|
1112
1140
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1113
|
-
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1114
1141
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1115
1142
|
return true
|
|
1116
1143
|
}
|
|
1144
|
+
const idleNow = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1117
1145
|
// The supervisor owns updates (children never self-update). It IPCs us when a newer
|
|
1118
|
-
// version is out → surface the chip; we report
|
|
1119
|
-
//
|
|
1146
|
+
// version is out → surface the chip; we report `between` (safe to restart now, per
|
|
1147
|
+
// Contract #1) + `idle` (the quiet window, for the unattended fallback) + whether a
|
|
1148
|
+
// web peer is present, so the supervisor restarts on an explicit click between turns
|
|
1149
|
+
// and otherwise waits for true idle with nobody watching (Slice 3).
|
|
1120
1150
|
process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
|
|
1121
|
-
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1151
|
+
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1122
1152
|
} else if (VERSION) {
|
|
1123
1153
|
// Foreground run (not the supervised service): it can't self-restart, but
|
|
1124
1154
|
// nudge once if a newer version is out so a relaunch with @latest picks it up.
|
|
@@ -1164,6 +1194,10 @@ async function shutdown(code = 0, farewell = true) {
|
|
|
1164
1194
|
// correct and must not hang on the (possibly dead) realtime socket. This is the
|
|
1165
1195
|
// bug behind the watchdog/auto-update raw process.exit() paths, which skipped
|
|
1166
1196
|
// teardown entirely and orphaned agent processes + left stale presence.
|
|
1197
|
+
// Flush structured session state synchronously BEFORE ending sessions. saveSession
|
|
1198
|
+
// debounces ~1.5s, so without this any events since the last write are lost on exit
|
|
1199
|
+
// (Contract #2). writeFileSync, well inside the 1500ms hard-exit backstop above.
|
|
1200
|
+
for (const s of sessions.values()) { try { s.flush?.() } catch { /* noop */ } }
|
|
1167
1201
|
for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
|
|
1168
1202
|
for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
|
|
1169
1203
|
detachLocal()
|
package/launcher.mjs
CHANGED
|
@@ -51,10 +51,14 @@ function serviceFile(room) {
|
|
|
51
51
|
export function detectState() {
|
|
52
52
|
const auth = readJson(path.join(CFG_DIR, 'auth.json'))
|
|
53
53
|
const provider = readJson(path.join(CFG_DIR, 'provider.json'))
|
|
54
|
+
// provider.json is written with { provider, baseUrl, model } (see provider.mjs).
|
|
55
|
+
// Anything whose `provider` field is set and not "anthropic" is a custom endpoint.
|
|
56
|
+
const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
|
|
54
57
|
return {
|
|
55
58
|
loggedIn: !!auth?.refresh_token,
|
|
56
59
|
email: auth?.email || auth?.user?.email || null,
|
|
57
|
-
provider:
|
|
60
|
+
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
61
|
+
providerModel: isCustom ? (provider.model || null) : null,
|
|
58
62
|
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
|
|
59
63
|
accountSvc: fs.existsSync(serviceFile(null)),
|
|
60
64
|
cwd: process.cwd(),
|
|
@@ -68,7 +72,11 @@ const C = {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
// io: { ask(prompt)->Promise<string>, print(s) }. actions: real | dry-run | mock.
|
|
71
|
-
|
|
75
|
+
// refresh: optional () => state — re-read disk after a mutating action so the header
|
|
76
|
+
// reflects the change (real bridge passes detectState; tests omit it to keep the
|
|
77
|
+
// injected state stable).
|
|
78
|
+
export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
|
|
79
|
+
const resync = () => { if (refresh) state = refresh() }
|
|
72
80
|
const askChoice = async (prompt, options, def = 1) => {
|
|
73
81
|
options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
|
|
74
82
|
const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
|
|
@@ -137,25 +145,41 @@ export async function runLauncher({ actions, io, state = detectState() }) {
|
|
|
137
145
|
await actions.installService({ room, agentCmd: agent.cmd })
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
const providerMenu = async () => {
|
|
149
|
+
const cur = state.provider + (state.providerModel ? `, model ${state.providerModel}` : '')
|
|
150
|
+
const p = await askChoice('\n provider', [
|
|
151
|
+
{ label: 'Anthropic (default)', hint: 'regular Claude login' },
|
|
152
|
+
{ label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' },
|
|
153
|
+
{ label: '← Back', hint: `current: ${cur}` },
|
|
154
|
+
], 3) // Enter = Back
|
|
155
|
+
if (p === 2) return
|
|
156
|
+
if (p === 0) { await actions.setProvider({ kind: 'anthropic' }); resync(); io.print('\n ' + C.green('✓ back on regular Claude.')); return }
|
|
157
|
+
// custom — every step can be aborted with a blank line, which backs out cleanly.
|
|
158
|
+
const base = (await io.ask(`\n Base url ${C.dim('(blank to cancel · SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
|
|
159
|
+
if (!base) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
160
|
+
const token = (await io.ask(` API token ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
|
|
161
|
+
if (!token) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
162
|
+
const model = (await io.ask(` Model ${C.dim('(blank = endpoint default · e.g. glm-4.6)')} ${C.cyan('▸')} `)).trim()
|
|
163
|
+
await actions.setProvider({ kind: 'custom', base, token, model: model || undefined })
|
|
164
|
+
resync()
|
|
165
|
+
io.print(`\n ${C.green('✓ provider set to custom.')}\n base url: ${state.provider}\n model: ${state.providerModel || '(endpoint default)'}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
140
168
|
const settingsMenu = async () => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
169
|
+
for (;;) {
|
|
170
|
+
const pick = await askChoice('\n change', [
|
|
171
|
+
{ label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
172
|
+
{ label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
173
|
+
{ label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
174
|
+
{ label: '← Back to main menu', hint: '' },
|
|
175
|
+
], 4) // Enter = Back
|
|
176
|
+
if (pick === 3) return
|
|
177
|
+
if (pick === 0) await providerMenu()
|
|
178
|
+
else if (pick === 1) { await actions.login(); resync(); return }
|
|
149
179
|
else {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
await actions.setProvider({ kind: 'custom', base, token })
|
|
180
|
+
if (state.accountSvc) await actions.uninstallService({ room: null })
|
|
181
|
+
else io.print(' ' + C.yellow('no background service installed'))
|
|
153
182
|
}
|
|
154
|
-
} else if (pick === 1) {
|
|
155
|
-
await actions.login()
|
|
156
|
-
} else {
|
|
157
|
-
if (state.accountSvc) await actions.uninstallService({ room: null })
|
|
158
|
-
else io.print(' ' + C.yellow('no background service installed'))
|
|
159
183
|
}
|
|
160
184
|
}
|
|
161
185
|
|
package/package.json
CHANGED
package/update-gate.mjs
CHANGED
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
// child is idle (a child reports idle=false while a turn is in flight — that's what
|
|
12
12
|
// keeps Contract #1), AND either a user requested apply (clicked the chip) OR — the
|
|
13
13
|
// unattended fallback — no child has a web peer present (nobody is there to click).
|
|
14
|
-
export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childPeer, requested = false }) {
|
|
14
|
+
export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childBetween, childPeer, requested = false }) {
|
|
15
15
|
if (!pendingUpdate || stopping) return false
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
if (requested) {
|
|
17
|
+
// Explicit "apply" → restart at the next BETWEEN-TURNS gap (no turn in flight),
|
|
18
|
+
// NOT the full quiet window — in an active room that 90s of silence never comes,
|
|
19
|
+
// so the click would otherwise do nothing. Fall back to childIdle when a child
|
|
20
|
+
// doesn't report `between` (older child during the mixed-version update itself).
|
|
21
|
+
const gate = childBetween instanceof Map ? childBetween : childIdle
|
|
22
|
+
for (const ready of gate.values()) if (!ready) return false // any busy child → wait (Contract #1)
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
for (const idle of childIdle.values()) if (!idle) return false // unattended: full idle window
|
|
18
26
|
for (const present of childPeer.values()) if (present) return false // someone's watching, no click → wait
|
|
19
27
|
return true // unattended: idle + nobody watching → auto-apply
|
|
20
28
|
}
|