@agfpd/iapeer 0.4.27 → 0.4.29
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/package.json +1 -1
- package/src/cli/index.ts +30 -1
- package/src/init/index.ts +36 -22
- package/src/launch/adapters/claude.ts +32 -0
- package/src/launch/types.ts +14 -0
- package/src/supervisor/boot.ts +22 -0
- package/src/supervisor/daemon.ts +41 -1
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1291,6 +1291,32 @@ export function renderUsage(width: number = helpWidth()): string {
|
|
|
1291
1291
|
return lines.join('\n') + '\n'
|
|
1292
1292
|
}
|
|
1293
1293
|
|
|
1294
|
+
/** Focused help for ONE verb: the VERBS entry whose signature's first token equals `verb`,
|
|
1295
|
+
* rendered as its own `usage: iapeer <sig>` + wrapped description — or null when `verb` is
|
|
1296
|
+
* not a known verb (the caller then falls back to the full renderUsage). This is why
|
|
1297
|
+
* `iapeer connect telegram --help` prints connect's OWN usage instead of the whole verb list
|
|
1298
|
+
* (the prior behavior: `--help` anywhere dumped the general usage, burying the subcommand the
|
|
1299
|
+
* user asked about). First-token match is unambiguous — no two VERBS share a leading token. */
|
|
1300
|
+
export function renderVerbHelp(verb: string, width: number = helpWidth()): string | null {
|
|
1301
|
+
const entry = VERBS.find(v => v.sig.split(/\s+/)[0] === verb)
|
|
1302
|
+
if (!entry) return null
|
|
1303
|
+
const W = Math.max(40, width)
|
|
1304
|
+
const lines = [`usage: iapeer ${entry.sig}`, ...wrapText(entry.desc, W - 2).map(d => ` ${d}`)]
|
|
1305
|
+
return lines.join('\n') + '\n'
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** Which verb's help an explicit help request targets, or null for the GENERAL usage:
|
|
1309
|
+
* - `<verb> --help` / `<verb> -h` → the verb (argv[0], when it is not itself a help token)
|
|
1310
|
+
* - `help <verb>` → argv[1]
|
|
1311
|
+
* - bare `help` / `--help` / `-h` → null (whole verb list)
|
|
1312
|
+
* An unknown target falls through to the general usage via renderVerbHelp returning null. */
|
|
1313
|
+
export function helpTargetVerb(argv: string[]): string | null {
|
|
1314
|
+
const first = argv[0]
|
|
1315
|
+
if (first && first !== 'help' && first !== '--help' && first !== '-h') return first
|
|
1316
|
+
if (first === 'help' && argv[1]) return argv[1]!
|
|
1317
|
+
return null
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1294
1320
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
1295
1321
|
const [verb, ...rest] = argv
|
|
1296
1322
|
// CLI hygiene (design «Onboard костяка» §CLI-гигиена): an explicit help request —
|
|
@@ -1301,7 +1327,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1301
1327
|
// match is safe: the look-ahead parser never consumes a `--`-token as a value, so
|
|
1302
1328
|
// a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
|
|
1303
1329
|
if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
|
1304
|
-
|
|
1330
|
+
// Per-verb help when a known verb is named (`connect telegram --help` → connect's own usage,
|
|
1331
|
+
// `help connect` → same); the general verb list only for a bare/unknown help request.
|
|
1332
|
+
const target = helpTargetVerb(argv)
|
|
1333
|
+
process.stdout.write((target && renderVerbHelp(target)) || renderUsage())
|
|
1305
1334
|
return 0
|
|
1306
1335
|
}
|
|
1307
1336
|
const { positionals, flags } = parseArgs(rest)
|
package/src/init/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// persistent-peer install-gate class is gone). A cold-start claude session
|
|
10
10
|
// (--dangerously-skip-permissions, as the launch primitive runs it) finds the
|
|
11
11
|
// `.mcp.json` http server and send_to_peer is callable on its FIRST turn. The server's
|
|
12
|
-
// project-approval is pre-granted by
|
|
12
|
+
// project-approval is pre-granted by ensureClaudeProjectSettings (the
|
|
13
13
|
// enableAllProjectMcpServers flag below); on a VIRGIN config without it the launch's
|
|
14
14
|
// bootDialogKeys clears the one-time "new MCP servers found" dialog as a backstop.
|
|
15
15
|
//
|
|
@@ -121,27 +121,33 @@ export function writeClaudeMcpConfig(cwd: string, personality: string, daemonUrl
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* Idempotently
|
|
125
|
-
*
|
|
124
|
+
* Idempotently merge the project-LOCAL claude settings that pre-empt BLOCKING startup/
|
|
125
|
+
* mid-session modals for a headless peer, into `<cwd>/.claude/settings.json`:
|
|
126
|
+
*
|
|
127
|
+
* - `"enableAllProjectMcpServers": true` — on a VIRGIN claude config, the FIRST launch
|
|
128
|
+
* in a cwd carrying a `.mcp.json` (writeClaudeMcpConfig above) shows a BLOCKING "N new
|
|
129
|
+
* MCP servers found in this project" approval dialog BEFORE the input prompt; a headless
|
|
130
|
+
* peer has no one to approve it, so the wake stalls. This flag pre-approves the cwd's MCP
|
|
131
|
+
* servers so the dialog never appears (verified live, claude 2.1.183). Set unconditionally.
|
|
132
|
+
* - `"tui": "default"` — pins the CLASSIC renderer so claude never pops its one-time
|
|
133
|
+
* "Try the new fullscreen renderer?" upsell modal (a BLOCKING mid-session nag that froze
|
|
134
|
+
* live fleet peers; empirically an explicit tui choice suppresses it — verified live). This
|
|
135
|
+
* is the PREVENTION belt to the supervisor nag-watcher: init keeps the modal from appearing
|
|
136
|
+
* for a new peer at all, the watcher dismisses the class on every peer mid-session. Set ONLY
|
|
137
|
+
* when ABSENT — never override an explicit operator/peer tui choice already in the file.
|
|
126
138
|
*
|
|
127
|
-
* Why: on a VIRGIN claude config, the FIRST launch in a cwd carrying a `.mcp.json`
|
|
128
|
-
* (writeClaudeMcpConfig above) shows a BLOCKING "N new MCP servers found in this
|
|
129
|
-
* project" approval dialog BEFORE the input prompt — a headless peer has no one to
|
|
130
|
-
* approve it, so the wake stalls. This project-LOCAL flag pre-approves the cwd's MCP
|
|
131
|
-
* servers so the dialog never appears (verified live, claude 2.1.183). It is
|
|
132
139
|
* PROJECT-scoped (this peer's cwd only) — it does NOT touch the user's GLOBAL
|
|
133
|
-
* ~/.claude/settings.json, so it never alters the user's own claude environment (the
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
140
|
+
* ~/.claude/settings.json, so it never alters the user's own claude environment (the same
|
|
141
|
+
* constraint that forbids a global skipDangerousModePermissionPrompt). Both modals also have
|
|
142
|
+
* runtime backstops (bootDialogKeys Enter-confirms the MCP dialog; the supervisor nag-watcher
|
|
143
|
+
* declines the fullscreen modal), so a peer whose cwd predates these keys still boots safely.
|
|
137
144
|
*
|
|
138
|
-
* NO-CLOBBER merge (the file may carry foreign blocks — plugin enables, statusline
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* bootDialogKeys backstop covers the dialog regardless).
|
|
145
|
+
* NO-CLOBBER merge (the file may carry foreign blocks — plugin enables, statusline wrappers,
|
|
146
|
+
* the native-memory lever): parse/patch + atomic write; a non-object settings.json is REFUSED
|
|
147
|
+
* rather than overwritten. Returns the written path, or null when nothing changed / on a refusal
|
|
148
|
+
* / on any error (best-effort, never throws — the runtime backstops cover the modals regardless).
|
|
143
149
|
*/
|
|
144
|
-
export function
|
|
150
|
+
export function ensureClaudeProjectSettings(cwd: string): string | null {
|
|
145
151
|
const path = claudeSettingsPath(cwd)
|
|
146
152
|
try {
|
|
147
153
|
let obj: Record<string, unknown> = {}
|
|
@@ -150,13 +156,21 @@ export function ensureClaudeProjectMcpEnabled(cwd: string): string | null {
|
|
|
150
156
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null // refuse to clobber a non-object
|
|
151
157
|
obj = raw as Record<string, unknown>
|
|
152
158
|
}
|
|
153
|
-
|
|
154
|
-
obj.enableAllProjectMcpServers
|
|
159
|
+
let changed = false
|
|
160
|
+
if (obj.enableAllProjectMcpServers !== true) {
|
|
161
|
+
obj.enableAllProjectMcpServers = true // pre-approve the cwd's MCP servers (no boot approval dialog)
|
|
162
|
+
changed = true
|
|
163
|
+
}
|
|
164
|
+
if (obj.tui === undefined) {
|
|
165
|
+
obj.tui = 'default' // classic renderer → no fullscreen-upsell modal; ABSENT-only (no override of an explicit choice)
|
|
166
|
+
changed = true
|
|
167
|
+
}
|
|
168
|
+
if (!changed) return path // both already in place — idempotent, no rewrite
|
|
155
169
|
mkdirSync(dirname(path), { recursive: true })
|
|
156
170
|
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
157
171
|
return path
|
|
158
172
|
} catch {
|
|
159
|
-
return null // best-effort; the bootDialogKeys
|
|
173
|
+
return null // best-effort; the bootDialogKeys / nag-watcher backstops still clear the modals
|
|
160
174
|
}
|
|
161
175
|
}
|
|
162
176
|
|
|
@@ -476,7 +490,7 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
476
490
|
// Pre-approve the cwd's project MCP servers so the FIRST virgin-config launch does
|
|
477
491
|
// not stall on the blocking "N new MCP servers found" dialog (project-local, never
|
|
478
492
|
// touches the user's global settings; bootDialogKeys is the backstop).
|
|
479
|
-
|
|
493
|
+
ensureClaudeProjectSettings(provisioned.cwd)
|
|
480
494
|
}
|
|
481
495
|
// codex: write the token-free host-wide config.toml block (dummy bearer flips codex's
|
|
482
496
|
// auth gate; the OPEN daemon authenticates by the identity header). send_to_peer works
|
|
@@ -240,6 +240,38 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
240
240
|
return null
|
|
241
241
|
},
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* MID-SESSION nag/upsell auto-dismiss (livability — distinct from bootDialogKeys,
|
|
245
|
+
* which the supervisor drives only until ready). claude pops a ONE-TIME interactive
|
|
246
|
+
* upsell modal AFTER the session is live that BLOCKS a headless peer (no human to
|
|
247
|
+
* answer):
|
|
248
|
+
*
|
|
249
|
+
* Try the new fullscreen renderer?
|
|
250
|
+
* · Flicker-free output …
|
|
251
|
+
* ❯ 1. Yes, try it
|
|
252
|
+
* 2. Not now
|
|
253
|
+
* Enter to confirm · Esc to cancel
|
|
254
|
+
*
|
|
255
|
+
* The default cursor sits on "1. Yes, try it", so a BARE Enter would ENABLE the
|
|
256
|
+
* fullscreen renderer — which switches claude to the alt-screen TUI and changes the
|
|
257
|
+
* surface the pane-log model + composer-occupancy + ready-gate all read off. We must
|
|
258
|
+
* DECLINE: type the literal '2' then Enter ('2','Enter' → "2\r"; '2' is not a named/
|
|
259
|
+
* cursor key, so it is emitted verbatim without a `-l` literal switch — and `-l`
|
|
260
|
+
* would wrongly make the following 'Enter' literal too). VERIFIED-SAFE on the live
|
|
261
|
+
* fleet: '2'+Enter cleared the modal on boris and doc and returned them to the
|
|
262
|
+
* composer; an arrow-step+Enter MIS-FIRED into "tui":"fullscreen" on scriber, and Esc/
|
|
263
|
+
* 'n' are unverified — so this is the ONLY accepted sequence. Match the FULL signature
|
|
264
|
+
* (title AND the "2. Not now" decline row) so a transcript merely DISCUSSING the
|
|
265
|
+
* fullscreen renderer can never trigger a stray keystroke. Future one-time CC upsells
|
|
266
|
+
* of this class extend the match here (each with its own verified-safe decline key).
|
|
267
|
+
*/
|
|
268
|
+
nagDismissKeys(pane: string): string[] | null {
|
|
269
|
+
if (pane.includes('Try the new fullscreen renderer?') && pane.includes('2. Not now')) {
|
|
270
|
+
return ['2', 'Enter']
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
},
|
|
274
|
+
|
|
243
275
|
/**
|
|
244
276
|
* Ready for the first message iff the input surface is rendered AND no startup
|
|
245
277
|
* dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
|
package/src/launch/types.ts
CHANGED
|
@@ -259,6 +259,20 @@ export interface RuntimeAdapter {
|
|
|
259
259
|
*/
|
|
260
260
|
bootDialogKeys(pane: string): string[] | null
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* MID-SESSION nag/upsell auto-dismiss (livability). Distinct from bootDialogKeys:
|
|
264
|
+
* the boot-driver stops at ready, but claude/codex can pop a ONE-TIME interactive
|
|
265
|
+
* upsell modal AFTER the session is live (e.g. "Try the new fullscreen renderer?")
|
|
266
|
+
* that BLOCKS the pty waiting for a keypress no headless peer answers — it froze the
|
|
267
|
+
* live fleet until a human cleared it. A persistent supervisor watcher (daemon.ts)
|
|
268
|
+
* runs THIS off the authoritative model for the whole session and writes the keys.
|
|
269
|
+
* Return the send-keys args that dismiss with the VERIFIED-SAFE default (decline),
|
|
270
|
+
* else null. Match the FULL modal signature so ordinary content mentioning the
|
|
271
|
+
* feature can never trigger a stray keystroke into the composer. Optional — a runtime
|
|
272
|
+
* with no known mid-session nags (codex/router) omits it. tui only.
|
|
273
|
+
*/
|
|
274
|
+
nagDismissKeys?(pane: string): string[] | null
|
|
275
|
+
|
|
262
276
|
/** Is the input surface ready for the first message? (tui: ready marker present
|
|
263
277
|
* AND startup dialogs gone). Router runtimes return true (no input surface). */
|
|
264
278
|
isInputReady(pane: string): boolean
|
package/src/supervisor/boot.ts
CHANGED
|
@@ -32,3 +32,25 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
|
|
|
32
32
|
if (adapter.isInputReady(viewport)) return { kind: 'ready' }
|
|
33
33
|
return { kind: 'wait' }
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional
|
|
37
|
+
* nagDismissKeys (claudeAdapter satisfies it; codex/router omit it → no nags). */
|
|
38
|
+
export interface NagPredicate {
|
|
39
|
+
nagDismissKeys?(pane: string): string[] | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type NagAction = { kind: 'dismiss'; keys: string[]; bytes: Buffer } | { kind: 'none' }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* MID-SESSION nag/upsell step (livability) — the persistent sibling of nextBootAction. Unlike the
|
|
46
|
+
* boot-driver, which STOPS at ready, the daemon loops this for the WHOLE session: a one-time CC upsell
|
|
47
|
+
* modal (e.g. "Try the new fullscreen renderer?") can pop AFTER the session is live and BLOCK the pty on
|
|
48
|
+
* a keypress no headless peer answers. If the adapter recognizes the (FULL-signature) modal it returns
|
|
49
|
+
* the verified-safe DECLINE keys; the caller writes `action.bytes` to the pty (cooldown-guarded so a
|
|
50
|
+
* cleared modal is never double-answered). An adapter with no nagDismissKeys → always 'none'.
|
|
51
|
+
*/
|
|
52
|
+
export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
|
|
53
|
+
const keys = adapter.nagDismissKeys?.(viewport)
|
|
54
|
+
if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
|
|
55
|
+
return { kind: 'none' }
|
|
56
|
+
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
stripQueries,
|
|
26
26
|
} from './protocol.ts'
|
|
27
27
|
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
|
|
28
|
-
import { nextBootAction, type BootPredicates } from './boot.ts'
|
|
28
|
+
import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
|
|
29
29
|
import { modelToPlainText } from './render.ts'
|
|
30
30
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
31
31
|
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
@@ -80,6 +80,12 @@ const BOOT_POLL_MS = 2000
|
|
|
80
80
|
// Stop driving after this long even if readiness is never observed (a hung/unknown startup); the
|
|
81
81
|
// driver is best-effort and never blocks the conduit, which keeps serving regardless.
|
|
82
82
|
const BOOT_DRIVE_MS = 120_000
|
|
83
|
+
// MID-SESSION nag-watcher cadence: same 2 s model poll as the boot-driver. After answering a nag, hold
|
|
84
|
+
// this long before the next check so a just-dismissed modal (claude repaints it away within a frame) is
|
|
85
|
+
// never re-answered — a stray keystroke would land in the now-visible composer. If the modal genuinely
|
|
86
|
+
// survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost.
|
|
87
|
+
const NAG_POLL_MS = 2000
|
|
88
|
+
const NAG_COOLDOWN_MS = 4000
|
|
83
89
|
|
|
84
90
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
85
91
|
interface PtyChild {
|
|
@@ -541,4 +547,38 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
541
547
|
}, BOOT_POLL_MS)
|
|
542
548
|
;(driver as { unref?: () => void }).unref?.()
|
|
543
549
|
}
|
|
550
|
+
|
|
551
|
+
// ── mid-session nag-watcher (livability) ────────────────────────────────────────
|
|
552
|
+
// The boot-driver above STOPS at ready. But claude/codex can pop a ONE-TIME upsell modal
|
|
553
|
+
// MID-SESSION (e.g. "Try the new fullscreen renderer?", which appears on the return-to-prompt
|
|
554
|
+
// boundary after a tool result) that BLOCKS the pty on a keypress no headless peer answers — it
|
|
555
|
+
// froze live fleet peers (boris, doc) until a human cleared it by hand. A PERSISTENT watcher answers
|
|
556
|
+
// the curated, verified-safe DECLINE for that class off the SAME authoritative @xterm model, for the
|
|
557
|
+
// WHOLE session lifetime, exactly like the boot-driver answers startup dialogs — model-read
|
|
558
|
+
// (modelToPlainText) + raw pty-byte-write (keysToBytes). Cooldown-guarded: after firing, hold
|
|
559
|
+
// NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in
|
|
560
|
+
// the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
|
|
561
|
+
const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
|
|
562
|
+
if (nagAdapter?.nagDismissKeys) {
|
|
563
|
+
let nagFiredMs = 0
|
|
564
|
+
const nagWatch = setInterval(() => {
|
|
565
|
+
if (down) {
|
|
566
|
+
clearInterval(nagWatch)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
if (Date.now() - nagFiredMs < NAG_COOLDOWN_MS) return // let a just-answered modal repaint away first
|
|
570
|
+
let action
|
|
571
|
+
try {
|
|
572
|
+
const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
|
|
573
|
+
action = nextNagAction(nagAdapter, modelToPlainText(xterm, cols, rows), enc)
|
|
574
|
+
} catch {
|
|
575
|
+
return // transient model read — retry next tick
|
|
576
|
+
}
|
|
577
|
+
if (action.kind === 'dismiss') {
|
|
578
|
+
child.terminal.write(action.bytes)
|
|
579
|
+
nagFiredMs = Date.now()
|
|
580
|
+
}
|
|
581
|
+
}, NAG_POLL_MS)
|
|
582
|
+
;(nagWatch as { unref?: () => void }).unref?.()
|
|
583
|
+
}
|
|
544
584
|
}
|