@agfpd/iapeer 0.2.96 → 0.2.97

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 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; detach with `Ctrl-]` and the session keeps running.
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,24 @@ 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/`](https://github.com/agfpd/iapeer/tree/main/docs) — the product side (what it is and how to use it); [`docs/internals/`](https://github.com/agfpd/iapeer/tree/main/docs/internals) — the mechanics from the inside. This repository is the implementation.
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.
74
84
 
75
85
  ## License
76
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.96",
3
+ "version": "0.2.97",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -63,7 +63,7 @@ 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'
66
+ import { MARKETPLACE_NAME, onboardHost, tccFullDiskAccessNote } from '../onboard/index.ts'
67
67
  import { readMemoryProvider } from '../status/index.ts'
68
68
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
69
69
  import { pluginLogsDir } from '../storage/index.ts'
@@ -1096,6 +1096,10 @@ 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
+ // macOS TCC advisory (manual grant — no flag/setting can): gated on a memory provider, since a
1100
+ // vault (often an iCloud Obsidian dir) is the TCC-protected resource. Base ~/.iapeer needs none.
1101
+ const tcc = tccFullDiskAccessNote({ memoryActive: mem.provider != null })
1102
+ if (tcc) out(tcc)
1099
1103
  return daemonFailed || r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
1100
1104
  }
1101
1105
  case 'native-memory': {
@@ -117,6 +117,14 @@ export const claudeAdapter: RuntimeAdapter = {
117
117
  * owner watches; the question goes "into the void". Default is the literal
118
118
  * 'AskUserQuestion'; the per-peer override (PEER_DISALLOWED_TOOLS empty =
119
119
  * allow all) is install-time launch.env, not this path.
120
+ * - '--add-dir','/' grants the file/edit/bash tools access OUTSIDE cwd (the
121
+ * CLI equivalent of settings `permissions.additionalDirectories`). A peer
122
+ * reads/writes its memory vault (often an iCloud Obsidian dir) and ~/.iapeer,
123
+ * both outside cwd. Passing it as a LAUNCH FLAG means a clean host needs NO
124
+ * manual `additionalDirectories` in the user's ~/.claude/settings.json — the
125
+ * requirement travels with the launch, reversible, no user-file mutation.
126
+ * (OS-level access to TCC-protected dirs — iCloud / Desktop / Documents — is
127
+ * a SEPARATE macOS grant, not coverable by any flag; onboard instructs it.)
120
128
  * - '--system-prompt-file', spec.systemPromptFile claude-start.sh:318 —
121
129
  * ONLY when set (a tui runtime that usesDoctrine composes one). Swaps the
122
130
  * CC coding baseline for the merged peer doctrine; plugin/MCP/CLAUDE.md
@@ -139,6 +147,8 @@ export const claudeAdapter: RuntimeAdapter = {
139
147
  '--dangerously-skip-permissions',
140
148
  '--disallowedTools',
141
149
  'AskUserQuestion',
150
+ '--add-dir',
151
+ '/',
142
152
  ...(spec.systemPromptFile ? ['--system-prompt-file', spec.systemPromptFile] : []),
143
153
  ...(spec.resume ? ['--continue'] : []),
144
154
  ...(spec.extraArgs ?? []),
@@ -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
@@ -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
- const off = Math.max(0, sz - SEED_BYTES)
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)
@@ -212,3 +212,27 @@ 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 or settings key can grant — it is
220
+ * user-granted in System Settings, by design. `~/.iapeer` lives under $HOME and is NOT TCC-protected,
221
+ * so a BASE install needs nothing. The trigger that warrants the grant is a memory vault — commonly an
222
+ * iCloud Obsidian dir, and iCloud Drive / Desktop / Documents / Downloads ARE TCC-protected — so the
223
+ * note is GATED on a memory provider being present; a base ~/.iapeer-only host is never nagged.
224
+ * Returns null when not applicable (caller prints nothing). Pure (no I/O) — unit-testable.
225
+ */
226
+ export function tccFullDiskAccessNote(opts: { memoryActive: boolean }): string | null {
227
+ if (!opts.memoryActive) return null
228
+ return (
229
+ [
230
+ 'macOS Full Disk Access (manual — TCC cannot be set by any flag or settings key):',
231
+ ' ~/.iapeer needs none (it is under $HOME). BUT a memory vault or a peer work dir in a',
232
+ ' TCC-protected location (iCloud Drive, Desktop, Documents, Downloads — e.g. an iCloud',
233
+ ' Obsidian vault) does, or file tools silently fail to read/write there.',
234
+ ' Grant it to ~/.local/bin/iapeer (and to Terminal too, if you attach) in:',
235
+ ' System Settings → Privacy & Security → Full Disk Access',
236
+ ].join('\n') + '\n'
237
+ )
238
+ }