@agfpd/iapeer 0.2.96 → 0.2.98
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/README.md +15 -4
- package/package.json +1 -1
- package/src/cli/index.ts +19 -2
- package/src/launch/adapters/claude.ts +26 -1
- package/src/launch/index.ts +23 -2
- package/src/launch/ptyHost.ts +7 -2
- package/src/launch/readyGateModel.ts +15 -4
- package/src/onboard/index.ts +122 -1
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ iapeer onboard
|
|
|
34
34
|
# 3. create your first agent
|
|
35
35
|
iapeer create assistant --runtime claude
|
|
36
36
|
|
|
37
|
-
# 4. talk to it live in your terminal — detach with Ctrl-]
|
|
37
|
+
# 4. talk to it live in your terminal — detach with Ctrl-] (or just close it; the agent keeps running)
|
|
38
38
|
iapeer attach assistant
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -49,7 +49,7 @@ The peer's name is the folder name, normalized (`myagent`). `create` sets up a f
|
|
|
49
49
|
|
|
50
50
|
Two ways to work with an agent locally in your terminal:
|
|
51
51
|
|
|
52
|
-
- **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep
|
|
52
|
+
- **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep. Detach with `Ctrl-]` — or just close the terminal — and the session keeps running in the background; reattach with `iapeer attach` whenever you need it.
|
|
53
53
|
- **Open a fresh session in the peer's folder** — go to the peer's directory and launch its runtime:
|
|
54
54
|
|
|
55
55
|
```sh
|
|
@@ -63,14 +63,25 @@ Agents reach each other with `send_to_peer`; you can also message any peer over
|
|
|
63
63
|
|
|
64
64
|
## What makes it different
|
|
65
65
|
|
|
66
|
-
- **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call.
|
|
66
|
+
- **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call (as of 2026-06-15, `claude -p` no longer counts against the subscription's main usage limits).
|
|
67
67
|
- **Warm-on-demand lifecycle.** A live session answers fast; after about an hour idle the daemon closes it; the next message brings it back with its context resumed. The message flow drives the lifecycle — not a human's presence.
|
|
68
68
|
- **One identity across runtimes, with one memory.** A peer's `personality` is decoupled from its runtime — the same peer runs on Claude or Codex, switched with a command. By default (set up by `iapeer onboard`) it carries one shared memory (iapeer-memory) keyed to the personality, not the runtime: one personality, one memory, whatever runtime it runs on.
|
|
69
69
|
- **Heterogeneous peers, one protocol.** AI agents, humans (Telegram), and services (timer = cron, watcher = event) are all first-class, addressed the same way over an always-on MCP daemon that exposes exactly one tool, `send_to_peer`.
|
|
70
70
|
|
|
71
71
|
## Documentation
|
|
72
72
|
|
|
73
|
-
[`docs/`](
|
|
73
|
+
[`docs/`](docs/README.md) — the product side: what it is and how to use it. Available in two languages — English (default) and [Русский](docs/ru/README.md). This repository is the implementation.
|
|
74
|
+
|
|
75
|
+
## Roadmap
|
|
76
|
+
|
|
77
|
+
Planned, not yet built:
|
|
78
|
+
|
|
79
|
+
- **Web UI** — a browser interface for working with the team.
|
|
80
|
+
- **Linux support** — iapeer runs on macOS today; Linux is planned.
|
|
81
|
+
- **More agent runtimes** — `opencode` and `antigravity`, two further CLI runtimes alongside Claude Code and Codex.
|
|
82
|
+
- **Discord runtime** — a human-presence runtime for Discord, alongside Telegram.
|
|
83
|
+
- **Permissions gate** — finer control over what an agent is allowed to do.
|
|
84
|
+
- **Approve permissions over Telegram** — approve an agent's permission requests from the chat; today permissions are auto-allowed by flags.
|
|
74
85
|
|
|
75
86
|
## License
|
|
76
87
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -63,8 +63,8 @@ import {
|
|
|
63
63
|
} from '../identity/profileStandard.ts'
|
|
64
64
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
65
65
|
import { ensureDaemonStarted, installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
66
|
-
import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
|
|
67
|
-
import { readMemoryProvider } from '../status/index.ts'
|
|
66
|
+
import { MARKETPLACE_NAME, onboardHost, runtimeAuthNote, tccFullDiskAccessNote } from '../onboard/index.ts'
|
|
67
|
+
import { probeFullDiskAccess, readMemoryProvider } from '../status/index.ts'
|
|
68
68
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
69
69
|
import { pluginLogsDir } from '../storage/index.ts'
|
|
70
70
|
|
|
@@ -1096,6 +1096,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1096
1096
|
})
|
|
1097
1097
|
const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
1098
1098
|
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
1099
|
+
// Runtime auth readiness (clean-host prerequisite): a peer runs the runtime's
|
|
1100
|
+
// interactive TUI; the launcher auto-clears first-run modals (theme/trust) but
|
|
1101
|
+
// CANNOT complete a login OAuth flow. Warn for every INSTALLED runtime (marketplace
|
|
1102
|
+
// state != runtime-missing) that has no positive auth evidence — before it ships a
|
|
1103
|
+
// peer that would fail to wake on the login screen.
|
|
1104
|
+
for (const m of r.marketplaces) {
|
|
1105
|
+
if (m.state === 'runtime-missing') continue
|
|
1106
|
+
const authNote = runtimeAuthNote(m.runtime, env)
|
|
1107
|
+
if (authNote) errOut(authNote + '\n')
|
|
1108
|
+
}
|
|
1109
|
+
// macOS TCC advisory (manual grant — no flag/setting/script can): PROBE-DRIVEN, not
|
|
1110
|
+
// memory-gated — a missing grant silently breaks file I/O (EPERM, no prompt/hang) on
|
|
1111
|
+
// any TCC-protected path (iCloud vault, Documents/Desktop/Downloads peer cwd). Silent
|
|
1112
|
+
// when granted / non-macOS / undeterminable.
|
|
1113
|
+
const { iapeerBinPath } = await import('../install/index.ts')
|
|
1114
|
+
const tcc = tccFullDiskAccessNote({ fda: probeFullDiskAccess(env), binPath: iapeerBinPath(env) })
|
|
1115
|
+
if (tcc) out(tcc)
|
|
1099
1116
|
return daemonFailed || r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
1100
1117
|
}
|
|
1101
1118
|
case 'native-memory': {
|
|
@@ -49,6 +49,12 @@ const CLAUDE_BOOT_DIALOG_MARKERS = [
|
|
|
49
49
|
'I am using this for local development',
|
|
50
50
|
'Resume from summary',
|
|
51
51
|
'Resuming the full session',
|
|
52
|
+
// First-run theme picker ("Let's get started. Choose the text style…") — appears
|
|
53
|
+
// on a CLEAN host (fresh config dir) BEFORE the input prompt, verified live on
|
|
54
|
+
// claude 2.1.181 in a pty. NOT removed by --dangerously-skip-permissions; if not
|
|
55
|
+
// cleared the peer never reaches ready. (There is NO separate "accept dangerous
|
|
56
|
+
// permissions" screen in 2.1.181 — verified absent.)
|
|
57
|
+
'Choose the text style',
|
|
52
58
|
] as const
|
|
53
59
|
|
|
54
60
|
function anyBootDialog(pane: string): boolean {
|
|
@@ -117,6 +123,14 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
117
123
|
* owner watches; the question goes "into the void". Default is the literal
|
|
118
124
|
* 'AskUserQuestion'; the per-peer override (PEER_DISALLOWED_TOOLS empty =
|
|
119
125
|
* allow all) is install-time launch.env, not this path.
|
|
126
|
+
* - '--add-dir','/' grants the file/edit/bash tools access OUTSIDE cwd (the
|
|
127
|
+
* CLI equivalent of settings `permissions.additionalDirectories`). A peer
|
|
128
|
+
* reads/writes its memory vault (often an iCloud Obsidian dir) and ~/.iapeer,
|
|
129
|
+
* both outside cwd. Passing it as a LAUNCH FLAG means a clean host needs NO
|
|
130
|
+
* manual `additionalDirectories` in the user's ~/.claude/settings.json — the
|
|
131
|
+
* requirement travels with the launch, reversible, no user-file mutation.
|
|
132
|
+
* (OS-level access to TCC-protected dirs — iCloud / Desktop / Documents — is
|
|
133
|
+
* a SEPARATE macOS grant, not coverable by any flag; onboard instructs it.)
|
|
120
134
|
* - '--system-prompt-file', spec.systemPromptFile claude-start.sh:318 —
|
|
121
135
|
* ONLY when set (a tui runtime that usesDoctrine composes one). Swaps the
|
|
122
136
|
* CC coding baseline for the merged peer doctrine; plugin/MCP/CLAUDE.md
|
|
@@ -139,6 +153,8 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
139
153
|
'--dangerously-skip-permissions',
|
|
140
154
|
'--disallowedTools',
|
|
141
155
|
'AskUserQuestion',
|
|
156
|
+
'--add-dir',
|
|
157
|
+
'/',
|
|
142
158
|
...(spec.systemPromptFile ? ['--system-prompt-file', spec.systemPromptFile] : []),
|
|
143
159
|
...(spec.resume ? ['--continue'] : []),
|
|
144
160
|
...(spec.extraArgs ?? []),
|
|
@@ -175,7 +191,16 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
175
191
|
if (
|
|
176
192
|
pane.includes('trust this folder') ||
|
|
177
193
|
pane.includes('Allow external CLAUDE.md file imports?') ||
|
|
178
|
-
pane.includes('I am using this for local development')
|
|
194
|
+
pane.includes('I am using this for local development') ||
|
|
195
|
+
// First-run theme picker (clean host): the default-highlighted row is always a
|
|
196
|
+
// valid theme (any choice is harmless for a headless peer), so a bare Enter
|
|
197
|
+
// accepts it. Without this the picker blocks the boot forever on a fresh host.
|
|
198
|
+
// NOTE: the LOGIN method picker that can follow on an UNAUTHENTICATED host is
|
|
199
|
+
// deliberately NOT auto-answered — selecting subscription opens a browser OAuth
|
|
200
|
+
// flow no headless peer can complete. Login is an onboard PREREQUISITE
|
|
201
|
+
// (ANTHROPIC_API_KEY or a one-time `claude login`); a never-authed host fails
|
|
202
|
+
// the wake LOUD at the boot deadline rather than auto-entering a dead OAuth.
|
|
203
|
+
pane.includes('Choose the text style')
|
|
179
204
|
) {
|
|
180
205
|
return ['Enter']
|
|
181
206
|
}
|
package/src/launch/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// marketplace / plugin install / update — that is install-time). lifecycle
|
|
14
14
|
// decides WHEN/HOW-MANY and hands launch a fully-resolved LaunchSpec + adapter.
|
|
15
15
|
|
|
16
|
-
import { mkdirSync } from 'fs'
|
|
16
|
+
import { existsSync, mkdirSync, statSync } from 'fs'
|
|
17
17
|
import { dirname } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
19
19
|
import { type Runtime } from '../core/constants.ts'
|
|
@@ -268,6 +268,13 @@ export const launch: LaunchFn = async (
|
|
|
268
268
|
const invocation = buildLaunchInvocation(spec, adapter, cfg)
|
|
269
269
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
|
|
270
270
|
const paneLogPath = `${cfg.logDir}/${identity}.log`
|
|
271
|
+
// SESSION-START BOUNDARY (premature-ready fix): the pane-log is a SHARED append-only file across a
|
|
272
|
+
// peer's session lineage. Capture its size BEFORE the supervisor opens+writes it, so the hosted
|
|
273
|
+
// ready-gate (waitHostReady → paneLogViewport) renders ONLY this session's bytes — never a PRIOR
|
|
274
|
+
// session's last frame, which (a `❯ ready` row) would satisfy isInputReady before the fresh runtime
|
|
275
|
+
// has painted → a premature deliver into a still-booting session → the first message is LOST and the
|
|
276
|
+
// model never produces a turn. Captured here, before startPtyHost spawns the writer (single writer).
|
|
277
|
+
const paneLogStartByte = existsSync(paneLogPath) ? statSync(paneLogPath).size : 0
|
|
271
278
|
const host = await startPtyHost({
|
|
272
279
|
identity,
|
|
273
280
|
runtime: spec.runtime,
|
|
@@ -293,11 +300,25 @@ export const launch: LaunchFn = async (
|
|
|
293
300
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
|
294
301
|
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
295
302
|
const isReady = await waitHostReady(
|
|
296
|
-
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs },
|
|
303
|
+
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte },
|
|
297
304
|
sleep,
|
|
298
305
|
)
|
|
299
306
|
if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
|
|
300
307
|
if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
|
|
308
|
+
// INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
|
|
309
|
+
// session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
|
|
310
|
+
// paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
|
|
311
|
+
// residual stale-frame read; a healthy fresh boot writes well beyond startByte before readiness.
|
|
312
|
+
appendLifecycleEvent(
|
|
313
|
+
cfg.exitLogDir,
|
|
314
|
+
{
|
|
315
|
+
ev: 'hosted-deliver',
|
|
316
|
+
identity,
|
|
317
|
+
paneLogStartByte,
|
|
318
|
+
paneLogBytes: existsSync(paneLogPath) ? statSync(paneLogPath).size : 0,
|
|
319
|
+
},
|
|
320
|
+
{ env },
|
|
321
|
+
)
|
|
301
322
|
const d = await deliverHosted(identity, firstMessage)
|
|
302
323
|
if (!d.ok) return fail(identity, `hosted deliver failed: ${d.error}`)
|
|
303
324
|
const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -160,15 +160,20 @@ export async function startPtyHost(b: {
|
|
|
160
160
|
* 2 s cadence + bootDeadline.
|
|
161
161
|
*/
|
|
162
162
|
export async function waitHostReady(
|
|
163
|
-
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number },
|
|
163
|
+
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number },
|
|
164
164
|
sleep: (ms: number) => Promise<void>,
|
|
165
165
|
): Promise<boolean> {
|
|
166
166
|
const log = `${b.logDir}/${b.identity}.log`
|
|
167
167
|
const iters = Math.max(1, Math.ceil(b.bootDeadlineSecs / 2))
|
|
168
|
+
// paneLogStartByte = the pane-log size at THIS session's spawn (captured by launch before the
|
|
169
|
+
// supervisor opens the shared append-only log). Passing it to the model render confines readiness to
|
|
170
|
+
// bytes THIS session wrote — without it a prior session's trailing `❯ ready` frame in the shared log
|
|
171
|
+
// satisfies isInputReady before the fresh runtime paints, causing a premature deliver into a booting
|
|
172
|
+
// session (the first message is then lost). See readyGateModel.loadPaneLogModel.
|
|
168
173
|
for (let i = 0; i < iters; i++) {
|
|
169
174
|
await sleep(2000)
|
|
170
175
|
if (!hostSessionAlive(b.identity)) return false
|
|
171
|
-
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS)
|
|
176
|
+
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS, b.paneLogStartByte ?? 0)
|
|
172
177
|
if (view && b.adapter.isInputReady(view)) return true
|
|
173
178
|
}
|
|
174
179
|
return false
|
|
@@ -89,14 +89,25 @@ export async function readyGateViewport(opts: ReadyGateViewportOpts): Promise<st
|
|
|
89
89
|
* (unreadable/short log, @xterm hiccup) — the caller decides the fallback (capture-pane for the
|
|
90
90
|
* readygate flip; a retry/fail for hosted).
|
|
91
91
|
*/
|
|
92
|
-
async function loadPaneLogModel(log: string, cols: number, rows: number): Promise<Terminal | null> {
|
|
92
|
+
async function loadPaneLogModel(log: string, cols: number, rows: number, startByte = 0): Promise<Terminal | null> {
|
|
93
93
|
if (!(cols > 0 && rows > 0) || !existsSync(log)) return null
|
|
94
94
|
try {
|
|
95
95
|
const { Terminal } = await import('@xterm/headless')
|
|
96
96
|
const fd = openSync(log, 'r')
|
|
97
97
|
try {
|
|
98
98
|
const sz = fstatSync(fd).size
|
|
99
|
-
|
|
99
|
+
// RENDER WINDOW = the SEED tail (last 4 MiB) AND, when given, the SESSION-START boundary. The
|
|
100
|
+
// pane-log is a SHARED append-only file across a peer's session lineage, so its tail can carry a
|
|
101
|
+
// PRIOR session's last frame — and a prior session almost always ended idle at a `❯ ready` row.
|
|
102
|
+
// Rendering that on a FRESH wake (before the new runtime has painted) makes isInputReady=true
|
|
103
|
+
// PREMATURELY → the gate delivers into a still-booting session → the first message is LOST and the
|
|
104
|
+
// model never produces a turn ("no activity advance"). `startByte` = the pane-log size captured at
|
|
105
|
+
// THIS session's spawn; clamping `off` to it renders ONLY this session's bytes, never the prior
|
|
106
|
+
// frame. Trim-safety: if capPaneLogs shrank the file BELOW startByte since capture (rare, mid-boot),
|
|
107
|
+
// the boundary is stale → fall back to the SEED tail (no worse than pre-fix; never hang on a 0-len
|
|
108
|
+
// read). startByte=0 (default) is byte-identical to the prior behavior.
|
|
109
|
+
const seedFloor = Math.max(0, sz - SEED_BYTES)
|
|
110
|
+
const off = startByte > 0 && startByte <= sz ? Math.max(startByte, seedFloor) : seedFloor
|
|
100
111
|
const buf = Buffer.alloc(sz - off)
|
|
101
112
|
readSync(fd, buf, 0, buf.length, off)
|
|
102
113
|
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
|
|
@@ -110,8 +121,8 @@ async function loadPaneLogModel(log: string, cols: number, rows: number): Promis
|
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
export async function paneLogViewport(log: string, cols: number, rows: number): Promise<string | null> {
|
|
114
|
-
const term = await loadPaneLogModel(log, cols, rows)
|
|
124
|
+
export async function paneLogViewport(log: string, cols: number, rows: number, startByte = 0): Promise<string | null> {
|
|
125
|
+
const term = await loadPaneLogModel(log, cols, rows, startByte)
|
|
115
126
|
if (!term) return null
|
|
116
127
|
const { modelToPlainText } = await import('../shadow/render.ts')
|
|
117
128
|
return modelToPlainText(term, cols, rows)
|
package/src/onboard/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { spawnSync } from 'child_process'
|
|
13
13
|
import { homedir } from 'os'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
-
import { accessSync, constants as FS } from 'fs'
|
|
15
|
+
import { accessSync, constants as FS, existsSync, readFileSync } from 'fs'
|
|
16
16
|
|
|
17
17
|
/** OUR marketplace — GitHub owner/repo, the source both runtimes' `marketplace add`
|
|
18
18
|
* takes; and the registered NAME both runtimes' `marketplace list` shows. */
|
|
@@ -212,3 +212,124 @@ export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
|
|
|
212
212
|
const noop = marketplaces.every(m => m.state !== 'registered')
|
|
213
213
|
return { marketplaces, noop }
|
|
214
214
|
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* macOS Full Disk Access (TCC) advisory printed at the END of onboard. The runtime permission layer
|
|
218
|
+
* (`--add-dir` / `additionalDirectories` for claude, the sandbox flags for codex) governs what the
|
|
219
|
+
* RUNTIME allows; macOS TCC is a SEPARATE OS layer that NO flag, settings key, config, env var or
|
|
220
|
+
* script can grant — it is user-granted in System Settings, by design (the TCC db is SIP-protected).
|
|
221
|
+
*
|
|
222
|
+
* PROBE-DRIVEN, not memory-gated (was: gated on a memory provider). The advisory now fires off the
|
|
223
|
+
* REAL FDA state (`fda` from probeFullDiskAccess), because a missing grant is a latent fault no proxy
|
|
224
|
+
* captures: the DEFAULT memory vault is an iCloud Obsidian dir AND a peer work dir can itself sit in a
|
|
225
|
+
* protected location (Documents/Desktop/Downloads) with NO memory provider at all. So: grant present
|
|
226
|
+
* → silent; grant ABSENT on macOS → warn, every time.
|
|
227
|
+
*
|
|
228
|
+
* Failure mode is SILENT, not a hang: per Apple (DTS/Quinn, devforums 678819) a launchd-spawned CLI
|
|
229
|
+
* with no GUI front gets NO TCC dialog — the access is denied with EPERM. A peer therefore does NOT
|
|
230
|
+
* block; its file I/O on a protected path just silently fails. `~/.iapeer` (under $HOME) is NOT
|
|
231
|
+
* TCC-protected and needs nothing.
|
|
232
|
+
*
|
|
233
|
+
* Returns null when granted / undeterminable / non-macOS (`fda` true or null). Pure (no I/O) —
|
|
234
|
+
* the FDA probe and binPath are injected by the caller, so this stays unit-testable.
|
|
235
|
+
*/
|
|
236
|
+
export function tccFullDiskAccessNote(opts: { fda: boolean | null; binPath: string }): string | null {
|
|
237
|
+
if (opts.fda !== false) return null // granted, non-macOS, or undeterminable → nothing to nag
|
|
238
|
+
return (
|
|
239
|
+
[
|
|
240
|
+
'macOS Full Disk Access NOT granted (manual — TCC cannot be set by any flag, settings key or script):',
|
|
241
|
+
' Without it, file I/O on TCC-protected paths SILENTLY FAILS with EPERM (no prompt, no hang) —',
|
|
242
|
+
' a peer reading/writing an iCloud Obsidian vault (iCloud Drive / Desktop / Documents / Downloads)',
|
|
243
|
+
' just gets denied. `~/.iapeer` itself is under $HOME and needs nothing.',
|
|
244
|
+
` Grant it to ${opts.binPath} (and to Terminal too, if you attach) in:`,
|
|
245
|
+
' System Settings → Privacy & Security → Full Disk Access',
|
|
246
|
+
].join('\n') + '\n'
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
// Runtime auth readiness — the one CLEAN-HOST modal a launcher CANNOT auto-clear
|
|
252
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
//
|
|
254
|
+
// A headless peer runs the runtime's INTERACTIVE TUI (a pty), not `-p`. On a clean
|
|
255
|
+
// host the first-run flow blocks on modals; the launcher auto-answers the ones it
|
|
256
|
+
// recognizes (theme picker, folder-trust, …) — but the LOGIN screen cannot be
|
|
257
|
+
// auto-answered (selecting subscription opens a browser OAuth flow no headless peer
|
|
258
|
+
// can complete). So login is a PREREQUISITE: the host must be authenticated BEFORE
|
|
259
|
+
// the first peer launches, or that peer's wake fails loud at the boot deadline.
|
|
260
|
+
//
|
|
261
|
+
// These probes are CONSERVATIVE: they return ready ONLY on positive evidence, so a
|
|
262
|
+
// missing signal nags (one harmless advisory line) rather than letting an
|
|
263
|
+
// unauthenticated host silently ship a peer that will fail to wake.
|
|
264
|
+
|
|
265
|
+
/** Resolve claude's config dir (CLAUDE_CONFIG_DIR override, else ~/.claude). */
|
|
266
|
+
function claudeConfigDir(env: NodeJS.ProcessEnv): string {
|
|
267
|
+
const override = env.CLAUDE_CONFIG_DIR?.trim()
|
|
268
|
+
if (override) return override
|
|
269
|
+
return join(env.HOME?.trim() || homedir(), '.claude')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* True when claude is authenticated for a headless launch (won't block on the login
|
|
274
|
+
* picker). Positive evidence, in order: an auth env var; a completed onboarding +
|
|
275
|
+
* account in `~/.claude.json` (the subscription `claude login` path); or an
|
|
276
|
+
* `apiKeyHelper` in settings.json. Anything else → not ready (nag). Reads are
|
|
277
|
+
* env-rooted (HOME/CLAUDE_CONFIG_DIR) so a hermetic test isolates them.
|
|
278
|
+
*/
|
|
279
|
+
export function claudeAuthReady(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
280
|
+
if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim() || env.CLAUDE_CODE_OAUTH_TOKEN?.trim()) {
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
// `~/.claude.json` lives at HOME root (not inside the config dir) in the default
|
|
284
|
+
// layout the fleet uses; hasCompletedOnboarding + an account/key marker proves the
|
|
285
|
+
// first-run flow (theme + login) is done.
|
|
286
|
+
const statePath = join(env.HOME?.trim() || homedir(), '.claude.json')
|
|
287
|
+
try {
|
|
288
|
+
const j = JSON.parse(readFileSync(statePath, 'utf8')) as Record<string, unknown>
|
|
289
|
+
if (j.hasCompletedOnboarding === true && (j.oauthAccount != null || j.customApiKeyResponses != null)) return true
|
|
290
|
+
} catch {
|
|
291
|
+
/* absent/unreadable → fall through to settings */
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const s = JSON.parse(readFileSync(join(claudeConfigDir(env), 'settings.json'), 'utf8')) as Record<string, unknown>
|
|
295
|
+
if (typeof s.apiKeyHelper === 'string' && s.apiKeyHelper.trim()) return true
|
|
296
|
+
} catch {
|
|
297
|
+
/* no settings / no helper */
|
|
298
|
+
}
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* True when codex is authenticated (won't block / error at startup). Positive
|
|
304
|
+
* evidence: `OPENAI_API_KEY` env, or an `auth.json` under $CODEX_HOME/~/.codex.
|
|
305
|
+
* Env-rooted for hermetic tests.
|
|
306
|
+
*/
|
|
307
|
+
export function codexAuthReady(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
308
|
+
if (env.OPENAI_API_KEY?.trim()) return true
|
|
309
|
+
const codexHome = env.CODEX_HOME?.trim()
|
|
310
|
+
const authPath = codexHome
|
|
311
|
+
? join(codexHome, 'auth.json')
|
|
312
|
+
: join(env.HOME?.trim() || homedir(), '.codex', 'auth.json')
|
|
313
|
+
return existsSync(authPath)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Advisory line for a runtime that is NOT authenticated (so the operator fixes it
|
|
318
|
+
* BEFORE launching peers). Returns null when the runtime is ready. Pure dispatch
|
|
319
|
+
* over the per-runtime probes above.
|
|
320
|
+
*/
|
|
321
|
+
export function runtimeAuthNote(runtime: OnboardRuntime, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
322
|
+
if (runtime === 'claude') {
|
|
323
|
+
if (claudeAuthReady(env)) return null
|
|
324
|
+
return (
|
|
325
|
+
'claude: NOT authenticated — the first peer would block on the login screen (a launcher cannot\n' +
|
|
326
|
+
' complete the browser OAuth flow). Before launching peers, either run `claude` once in your\n' +
|
|
327
|
+
' terminal to sign in (subscription), or set ANTHROPIC_API_KEY in the daemon environment.'
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
if (codexAuthReady(env)) return null
|
|
331
|
+
return (
|
|
332
|
+
'codex: NOT authenticated — the first codex peer cannot reach the model. Before launching codex\n' +
|
|
333
|
+
' peers, run `codex login` once (or set OPENAI_API_KEY in the daemon environment).'
|
|
334
|
+
)
|
|
335
|
+
}
|