@agfpd/iapeer 0.1.0
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/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// init — the per-peer onboarding verb (`iapeer init`, contract Примитивы §init +
|
|
2
|
+
// Установка §INIT). DETERMINISTIC, non-interactive. Builds on provisionPeer (identity
|
|
3
|
+
// + registry + infra plist) and adds the per-peer ECOSYSTEM wiring a peer needs to
|
|
4
|
+
// talk: the HTTP-MCP transport config + the local doctrine template.
|
|
5
|
+
//
|
|
6
|
+
// THE BIG SIMPLIFICATION (install-gate snapped LIVE 08.06): a peer gets send_to_peer
|
|
7
|
+
// purely from a project-scope `.mcp.json` pointing at the host-wide HTTP-MCP daemon —
|
|
8
|
+
// NO plugin install, NO /reload-plugins, NO GC version-snapshots (the whole legacy
|
|
9
|
+
// persistent-peer install-gate class is gone). Verified: a cold-start claude session
|
|
10
|
+
// (--dangerously-skip-permissions, as the launch primitive runs it) auto-enables the
|
|
11
|
+
// `.mcp.json` http server and send_to_peer is callable on its FIRST turn, no approve.
|
|
12
|
+
//
|
|
13
|
+
// claude side here. codex side (`[mcp_servers.<name>]` in ~/.codex/config.toml +
|
|
14
|
+
// default_tools_approval_mode="approve") lands with its own live codex check.
|
|
15
|
+
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
17
|
+
import { homedir } from 'os'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, IAPEER_DIR, isInfraRuntime, type Runtime } from '../core/constants.ts'
|
|
20
|
+
import {
|
|
21
|
+
ensureLocalIapScaffold,
|
|
22
|
+
pluginStateDir,
|
|
23
|
+
writeFileAtomic,
|
|
24
|
+
type StorageOptions,
|
|
25
|
+
} from '../storage/index.ts'
|
|
26
|
+
import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
|
|
27
|
+
import { launchctlBootstrap, launchdPlistPath, type BootstrapResult } from '../launch/launchd.ts'
|
|
28
|
+
import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
|
|
29
|
+
import type { Intelligence } from '../core/constants.ts'
|
|
30
|
+
|
|
31
|
+
/** The MCP-server name the peer's `.mcp.json` uses for the foundation daemon. */
|
|
32
|
+
export const IAPEER_MCP_SERVER_NAME = 'iapeer'
|
|
33
|
+
|
|
34
|
+
/** Fallback HTTP-MCP daemon port when no router.json is published yet (the daemon
|
|
35
|
+
* binds this stable loopback port by default; init before daemon-start uses it). */
|
|
36
|
+
export const DEFAULT_DAEMON_MCP_PORT = 8765
|
|
37
|
+
|
|
38
|
+
const IAPEER_DOCTRINE_FILE = 'IAPEER.md'
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// Daemon URL resolution (router.json published by the daemon, else the default)
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the daemon HTTP-MCP URL to write into a peer's `.mcp.json`. Primary: the
|
|
46
|
+
* `tcp` field of the daemon's discovery file `~/.iapeer/state/iapeer/router.json`
|
|
47
|
+
* (published live by the daemon). Fallback: the well-known default loopback URL —
|
|
48
|
+
* so `iapeer init` works even before the daemon is started (the daemon binds the
|
|
49
|
+
* same stable port; IAPEER_PORT overrides it at the daemon, not here).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveDaemonMcpUrl(options: StorageOptions = {}): string {
|
|
52
|
+
const routerJson = join(pluginStateDir('iapeer', options), 'router.json')
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
|
|
55
|
+
if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp
|
|
56
|
+
} catch {
|
|
57
|
+
/* no router.json (daemon not started) → the well-known default below */
|
|
58
|
+
}
|
|
59
|
+
const env = options.env ?? process.env
|
|
60
|
+
const port = env.IAPEER_PORT?.trim() || String(DEFAULT_DAEMON_MCP_PORT)
|
|
61
|
+
return `http://127.0.0.1:${port}/mcp`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// claude `.mcp.json` wiring (project-scope; auto-enables on cold-start)
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
interface McpHttpServer {
|
|
69
|
+
type: 'http'
|
|
70
|
+
url: string
|
|
71
|
+
headers: Record<string, string>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Idempotently wire the foundation HTTP-MCP server into the peer's project-scope
|
|
76
|
+
* `<cwd>/.mcp.json` (claude-specific). MERGE, not clobber: existing mcpServers are
|
|
77
|
+
* preserved; only the `iapeer` entry is (re)written. The X-IAPeer-Identity header
|
|
78
|
+
* carries `claude-<personality>` so the daemon resolves the caller per request. The
|
|
79
|
+
* caller is authenticated by that header (the same proof live-verified at cold-start).
|
|
80
|
+
* Returns the written path. Re-running init is a no-op-equivalent (same bytes).
|
|
81
|
+
*/
|
|
82
|
+
export function writeClaudeMcpConfig(cwd: string, personality: string, daemonUrl: string): string {
|
|
83
|
+
const path = join(cwd, '.mcp.json')
|
|
84
|
+
let doc: { mcpServers?: Record<string, unknown> } = {}
|
|
85
|
+
if (existsSync(path)) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
88
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) doc = parsed
|
|
89
|
+
} catch {
|
|
90
|
+
// Malformed existing .mcp.json. Starting fresh would SILENTLY discard the
|
|
91
|
+
// operator's other mcpServers (audit #15/#22 — data loss). Back the original
|
|
92
|
+
// up VERBATIM first so re-init never loses foreign config; then proceed.
|
|
93
|
+
try {
|
|
94
|
+
copyFileSync(path, `${path}.corrupt.bak`)
|
|
95
|
+
} catch {
|
|
96
|
+
/* best-effort backup */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const server: McpHttpServer = {
|
|
101
|
+
type: 'http',
|
|
102
|
+
url: daemonUrl,
|
|
103
|
+
headers: { 'X-IAPeer-Identity': `claude-${personality}` },
|
|
104
|
+
}
|
|
105
|
+
doc.mcpServers = { ...(doc.mcpServers ?? {}), [IAPEER_MCP_SERVER_NAME]: server }
|
|
106
|
+
writeFileAtomic(path, `${JSON.stringify(doc, null, 2)}\n`, 0o644)
|
|
107
|
+
return path
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// codex MCP config (~/.codex/config.toml — HOST-WIDE; the token-free recipe)
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
//
|
|
114
|
+
// codex's CLI does NOT import tools from an OPEN streamable-HTTP MCP server — it marks
|
|
115
|
+
// it authStatus=unsupported (and BLOCKS on startup) unless an auth scheme is configured
|
|
116
|
+
// (codex bug #21532 / #4707). The token-free fix (PROVEN LIVE, codex 0.136, owner
|
|
117
|
+
// decision 08.06 — Артур rejected a real host-wide bearer as a localhost crutch): set a
|
|
118
|
+
// FIXED, NON-SECRET bearer (CODEX_DUMMY_BEARER). Setting `bearer_token_env_var` flips
|
|
119
|
+
// authStatus to `bearer_token` purely from the config FACT — codex does NOT require the
|
|
120
|
+
// server to validate the token. The daemon stays OPEN: it ignores `Authorization` and
|
|
121
|
+
// resolves the caller from the X-IAPeer-Identity header (the SAME loopback same-uid +
|
|
122
|
+
// per-peer-identity auth as the claude side). So NEITHER the daemon NOR the claude side
|
|
123
|
+
// changes; only codex's config + the launch env do. The launch sets CODEX_BEARER_ENV_VAR
|
|
124
|
+
// (=CODEX_DUMMY_BEARER) and PEER_IDENTITY; env_http_headers carries the latter per-peer.
|
|
125
|
+
|
|
126
|
+
export { CODEX_BEARER_ENV_VAR } from '../core/constants.ts'
|
|
127
|
+
|
|
128
|
+
/** `~/.codex/config.toml` (or $CODEX_HOME/config.toml). codex's config is HOST-WIDE,
|
|
129
|
+
* not per-peer/project-scope like claude's `.mcp.json`. */
|
|
130
|
+
export function codexConfigPath(options: StorageOptions = {}): string {
|
|
131
|
+
const env = options.env ?? process.env
|
|
132
|
+
const home = env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), '.codex')
|
|
133
|
+
return join(home, 'config.toml')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Idempotently add the token-free `[mcp_servers.iapeer]` block to codex's host-wide
|
|
138
|
+
* config.toml (append-if-absent — never duplicates, never rewrites an existing block,
|
|
139
|
+
* never touches other servers/sections). The block (PROVEN LIVE — codex imports +
|
|
140
|
+
* calls send_to_peer with this exact shape):
|
|
141
|
+
* - url = the daemon HTTP-MCP endpoint.
|
|
142
|
+
* - default_tools_approval_mode = "approve" (no per-tool approval dialog; verified live).
|
|
143
|
+
* - bearer_token_env_var = "IAPEER_BEARER" — codex reads its bearer from this env var
|
|
144
|
+
* (the launch sets it to the NON-SECRET CODEX_DUMMY_BEARER). Its mere presence flips
|
|
145
|
+
* authStatus unsupported→bearer_token, so codex imports the tools; the OPEN daemon
|
|
146
|
+
* ignores the bearer (it authenticates by the identity header below).
|
|
147
|
+
* - env_http_headers."X-IAPeer-Identity" = "PEER_IDENTITY" — the PER-PEER caller
|
|
148
|
+
* identity, read from the PEER_IDENTITY env the launch sets, so ONE host-wide config
|
|
149
|
+
* serves every codex peer with its own identity (env_http_headers, verified live).
|
|
150
|
+
* Returns {path, added} (added=false when the block already existed).
|
|
151
|
+
*/
|
|
152
|
+
export function writeCodexMcpConfig(daemonUrl: string, options: StorageOptions = {}): { path: string; added: boolean } {
|
|
153
|
+
const path = codexConfigPath(options)
|
|
154
|
+
let existing = ''
|
|
155
|
+
try {
|
|
156
|
+
existing = readFileSync(path, 'utf8')
|
|
157
|
+
} catch {
|
|
158
|
+
/* no config yet → create it */
|
|
159
|
+
}
|
|
160
|
+
if (/\[mcp_servers\.iapeer\]/.test(existing)) return { path, added: false } // idempotent
|
|
161
|
+
const block =
|
|
162
|
+
`\n[mcp_servers.${IAPEER_MCP_SERVER_NAME}]\n` +
|
|
163
|
+
`url = ${JSON.stringify(daemonUrl)}\n` +
|
|
164
|
+
`default_tools_approval_mode = "approve"\n` +
|
|
165
|
+
`bearer_token_env_var = "${CODEX_BEARER_ENV_VAR}"\n` +
|
|
166
|
+
`\n[mcp_servers.${IAPEER_MCP_SERVER_NAME}.env_http_headers]\n` +
|
|
167
|
+
`"X-IAPeer-Identity" = "PEER_IDENTITY"\n`
|
|
168
|
+
// Atomic write of the host-wide SHARED ~/.codex/config.toml (audit #12/#14): a torn
|
|
169
|
+
// writeFileSync could corrupt the operator's whole codex config. writeFileAtomic
|
|
170
|
+
// does tmp+fsync+rename and creates the dir.
|
|
171
|
+
writeFileAtomic(path, existing.replace(/\n*$/, '\n') + block, 0o644)
|
|
172
|
+
return { path, added: true }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Local doctrine template (<cwd>/.iapeer/IAPEER.md — personality/role; human fills)
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create the local doctrine template `<cwd>/.iapeer/IAPEER.md` if absent (contract:
|
|
181
|
+
* "шаблон .iapeer/IAPEER.md — пустой, человек заполняет"). It is the file that marks
|
|
182
|
+
* "this is a configured peer" (the launch primitive's bare-session gate keys on it)
|
|
183
|
+
* and where the peer's role/personality lives (merged into the system prompt, Канал
|
|
184
|
+
* A). NEVER overwrites an existing doctrine. Returns {path, created}.
|
|
185
|
+
*/
|
|
186
|
+
export function ensureDoctrineTemplate(cwd: string): { path: string; created: boolean } {
|
|
187
|
+
const path = join(cwd, IAPEER_DIR, IAPEER_DOCTRINE_FILE)
|
|
188
|
+
if (existsSync(path)) return { path, created: false }
|
|
189
|
+
ensureLocalIapScaffold(cwd)
|
|
190
|
+
writeFileSync(
|
|
191
|
+
path,
|
|
192
|
+
[
|
|
193
|
+
'# Peer doctrine',
|
|
194
|
+
'',
|
|
195
|
+
'<!-- This is the local doctrine for this peer — its role, personality, and mandate.',
|
|
196
|
+
' It is merged into the system prompt at launch (Канал A). Replace this with',
|
|
197
|
+
' who this peer is and what it does. An empty doctrine launches a bare peer. -->',
|
|
198
|
+
'',
|
|
199
|
+
].join('\n'),
|
|
200
|
+
{ mode: 0o644 },
|
|
201
|
+
)
|
|
202
|
+
return { path, created: true }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// initPeer — orchestrate: provision (identity + registry + infra plist) + per-peer
|
|
207
|
+
// ecosystem wiring (MCP transport per agentic runtime + doctrine template)
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the PRIMARY runtime for a peer cwd, runtime-aware and CONSISTENT with the
|
|
212
|
+
* contract (Примитивы §init): an explicit runtime wins; otherwise the runtime markers
|
|
213
|
+
* IN the cwd decide — `.claude` → claude, `.codex`-only → codex, both → claude primary
|
|
214
|
+
* (the agentic default order). No marker at all → claude (the default). This removes
|
|
215
|
+
* the old inconsistency where a `.codex`-only folder defaulted to claude as primary
|
|
216
|
+
* yet got a codex config — now the primary matches the markers, deterministically.
|
|
217
|
+
*/
|
|
218
|
+
export function resolvePrimaryRuntime(cwd: string, explicit?: Runtime): Runtime {
|
|
219
|
+
if (explicit) return explicit
|
|
220
|
+
if (existsSync(join(cwd, '.claude'))) return 'claude'
|
|
221
|
+
if (existsSync(join(cwd, '.codex'))) return 'codex'
|
|
222
|
+
return 'claude'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface InitPeerOptions {
|
|
226
|
+
cwd: string
|
|
227
|
+
/** Primary runtime. Default: resolved from the cwd's runtime markers
|
|
228
|
+
* (resolvePrimaryRuntime) — `.claude` → claude, `.codex`-only → codex, else claude.
|
|
229
|
+
* Agentic peers init claude/codex; an infra peer (telegram/notifier) is provisioned
|
|
230
|
+
* but has no `.mcp.json` (it is a router, not an MCP client). */
|
|
231
|
+
runtime?: Runtime
|
|
232
|
+
personality?: string
|
|
233
|
+
description?: string
|
|
234
|
+
intelligence?: Intelligence
|
|
235
|
+
/** For an INFRA runtime: absolute path / PATH name of the runtime launcher, baked
|
|
236
|
+
* into the always-on plist (so launchd's minimal PATH resolves it). */
|
|
237
|
+
runtimeBin?: string
|
|
238
|
+
/** AUTO-bootstrap a freshly-installed INFRA plist (launchctl bootstrap) instead of
|
|
239
|
+
* write-and-wait (contract Фаза §5). Default true; only acts for an infra runtime
|
|
240
|
+
* whose plist is foundation-owned. Sandbox/foreign cases are guarded inside
|
|
241
|
+
* launchctlBootstrap. No-op for an agentic (warm-on-demand) runtime. */
|
|
242
|
+
bootstrap?: boolean
|
|
243
|
+
env?: NodeJS.ProcessEnv
|
|
244
|
+
warn?: (message: string) => void
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface InitPeerResult extends ProvisionResult {
|
|
248
|
+
/** `.mcp.json` paths written (claude project-scope transport configs). */
|
|
249
|
+
mcpConfigPaths: string[]
|
|
250
|
+
/** codex config.toml path written with the token-free `[mcp_servers.iapeer]` block
|
|
251
|
+
* (dummy bearer + env_http_headers identity), or undefined when the peer is not
|
|
252
|
+
* codex. send_to_peer works immediately — see writeCodexMcpConfig. */
|
|
253
|
+
codexMcpConfigPath?: string
|
|
254
|
+
doctrinePath: string
|
|
255
|
+
doctrineCreated: boolean
|
|
256
|
+
daemonUrl: string
|
|
257
|
+
/** For an INFRA runtime: the per-peer self-config hook outcome (configured / failed /
|
|
258
|
+
* absent when no runtime package declares one). Undefined for an agentic peer. */
|
|
259
|
+
selfConfig?: SelfConfigResult
|
|
260
|
+
/** For an INFRA runtime with bootstrap enabled: the launchctl bootstrap outcome
|
|
261
|
+
* (loaded / already-loaded / skipped-sandbox / refused-foreign / failed). Undefined
|
|
262
|
+
* for an agentic peer, when bootstrap was disabled, or when self-config failed (a
|
|
263
|
+
* misconfigured always-on session is never loaded). */
|
|
264
|
+
bootstrapped?: BootstrapResult
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Initialise a peer in one call: provision (identity + registry + infra plist) then
|
|
269
|
+
* the per-peer ecosystem wiring. For each AGENTIC runtime the peer declares, write
|
|
270
|
+
* the HTTP-MCP transport config (claude → project `.mcp.json`; codex → follow-up) so
|
|
271
|
+
* the peer has send_to_peer on cold-start. Always lay down the local doctrine
|
|
272
|
+
* template. Idempotent: re-running rewrites the same `.mcp.json` and never clobbers
|
|
273
|
+
* an existing doctrine.
|
|
274
|
+
*/
|
|
275
|
+
export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
276
|
+
const env = opts.env ?? process.env
|
|
277
|
+
// Runtime-aware + CONSISTENT: an explicit runtime wins; else the cwd's markers
|
|
278
|
+
// decide the primary (resolvePrimaryRuntime), so the primary always matches the
|
|
279
|
+
// config that gets written (no `.codex`-only-but-primary-claude mismatch).
|
|
280
|
+
const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime)
|
|
281
|
+
const provisioned = await provisionPeer({
|
|
282
|
+
cwd: opts.cwd,
|
|
283
|
+
runtime,
|
|
284
|
+
personality: opts.personality,
|
|
285
|
+
description: opts.description,
|
|
286
|
+
intelligence: opts.intelligence,
|
|
287
|
+
runtimeBin: opts.runtimeBin,
|
|
288
|
+
env,
|
|
289
|
+
warn: opts.warn,
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const daemonUrl = resolveDaemonMcpUrl({ env })
|
|
293
|
+
const mcpConfigPaths: string[] = []
|
|
294
|
+
// Wire claude when the peer is a claude peer — primary runtime claude OR a `.claude`
|
|
295
|
+
// marker in the cwd (a multi-runtime peer). codex (config.toml) is a separate
|
|
296
|
+
// follow-up (its own live approval-mode check); an infra runtime is a router (no
|
|
297
|
+
// MCP client), so it gets no `.mcp.json`.
|
|
298
|
+
if (provisioned.runtime === 'claude' || hasClaudeMarker(provisioned.cwd)) {
|
|
299
|
+
mcpConfigPaths.push(writeClaudeMcpConfig(provisioned.cwd, provisioned.personality, daemonUrl))
|
|
300
|
+
}
|
|
301
|
+
// codex: write the token-free host-wide config.toml block (dummy bearer flips codex's
|
|
302
|
+
// auth gate; the OPEN daemon authenticates by the identity header). send_to_peer works
|
|
303
|
+
// immediately. An infra runtime is a router → no MCP client → nothing written.
|
|
304
|
+
let codexMcpConfigPath: string | undefined
|
|
305
|
+
if (provisioned.runtime === 'codex' || hasCodexMarker(provisioned.cwd)) {
|
|
306
|
+
codexMcpConfigPath = writeCodexMcpConfig(daemonUrl, { env }).path
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const doctrine = ensureDoctrineTemplate(provisioned.cwd)
|
|
310
|
+
|
|
311
|
+
// INFRA peer: per-peer runtime self-config, THEN auto-bootstrap. For an agentic
|
|
312
|
+
// (warm-on-demand) runtime neither applies — it is daemon-woken, never launchd-held,
|
|
313
|
+
// and gets its MCP wiring above instead.
|
|
314
|
+
let selfConfig: SelfConfigResult | undefined
|
|
315
|
+
let bootstrapped: BootstrapResult | undefined
|
|
316
|
+
if (isInfraRuntime(provisioned.runtime)) {
|
|
317
|
+
// (1) PER-PEER self-config (the shared contract both provision modes call): ask the
|
|
318
|
+
// runtime package to "configure runtime state for this peer". A no-op when no
|
|
319
|
+
// runtime package declares a hook (selfConfig.state='absent').
|
|
320
|
+
selfConfig = runtimeSelfConfig(
|
|
321
|
+
{
|
|
322
|
+
personality: provisioned.personality,
|
|
323
|
+
cwd: provisioned.cwd,
|
|
324
|
+
runtime: provisioned.runtime,
|
|
325
|
+
intelligence: provisioned.intelligence,
|
|
326
|
+
},
|
|
327
|
+
{ env },
|
|
328
|
+
)
|
|
329
|
+
if (selfConfig.state === 'failed') {
|
|
330
|
+
// FAIL-CLOSED: never load an always-on session whose runtime state is not
|
|
331
|
+
// configured (it would crash-loop). The plist is written (idempotent re-run
|
|
332
|
+
// after a fix will bootstrap); we just do not load it now.
|
|
333
|
+
opts.warn?.(`runtime self-config failed for ${provisioned.personality}: ${selfConfig.detail ?? ''} — NOT bootstrapping`)
|
|
334
|
+
} else if (opts.bootstrap !== false) {
|
|
335
|
+
// (2) AUTO-bootstrap (contract Фаза §5): load the plist NOW instead of
|
|
336
|
+
// write-and-wait. Fleet-safe / idempotent / sandbox-skipped inside.
|
|
337
|
+
bootstrapped = launchctlBootstrap(provisioned.personality, launchdPlistPath(provisioned.personality, env), env)
|
|
338
|
+
if (bootstrapped.state === 'failed' || bootstrapped.state === 'refused-foreign') {
|
|
339
|
+
opts.warn?.(`bootstrap ${bootstrapped.state}: ${bootstrapped.detail ?? ''}`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
...provisioned,
|
|
346
|
+
mcpConfigPaths,
|
|
347
|
+
codexMcpConfigPath,
|
|
348
|
+
doctrinePath: doctrine.path,
|
|
349
|
+
doctrineCreated: doctrine.created,
|
|
350
|
+
daemonUrl,
|
|
351
|
+
selfConfig,
|
|
352
|
+
bootstrapped,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A cwd is a claude/codex peer when it carries the runtime's marker dir (init scans
|
|
357
|
+
* the same markers as discoverPeerRuntimes). */
|
|
358
|
+
function hasClaudeMarker(cwd: string): boolean {
|
|
359
|
+
return existsSync(join(cwd, '.claude'))
|
|
360
|
+
}
|
|
361
|
+
function hasCodexMarker(cwd: string): boolean {
|
|
362
|
+
return existsSync(join(cwd, '.codex'))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
366
|
+
// CLI — `iapeer init` (run from the peer cwd) / `bun src/init/index.ts [cwd]`
|
|
367
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
if (import.meta.main) {
|
|
370
|
+
const args = process.argv.slice(2)
|
|
371
|
+
const flags: Record<string, string> = {}
|
|
372
|
+
const positionals: string[] = []
|
|
373
|
+
for (let i = 0; i < args.length; i++) {
|
|
374
|
+
if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i] ?? ''
|
|
375
|
+
else positionals.push(args[i])
|
|
376
|
+
}
|
|
377
|
+
const cwd = positionals[0] ?? process.cwd()
|
|
378
|
+
initPeer({
|
|
379
|
+
cwd,
|
|
380
|
+
runtime: (flags.runtime as Runtime) || undefined,
|
|
381
|
+
personality: flags.personality,
|
|
382
|
+
description: flags.description,
|
|
383
|
+
intelligence: flags.intelligence as Intelligence | undefined,
|
|
384
|
+
runtimeBin: flags.bin || undefined,
|
|
385
|
+
bootstrap: 'no-bootstrap' in flags ? false : undefined,
|
|
386
|
+
warn: m => process.stderr.write(`warn: ${m}\n`),
|
|
387
|
+
})
|
|
388
|
+
.then(r => {
|
|
389
|
+
process.stdout.write(
|
|
390
|
+
`initialized peer "${r.personality}" (${r.runtime}, ${r.intelligence})\n` +
|
|
391
|
+
` profile: ${r.profilePath}\n` +
|
|
392
|
+
` registry: peers-profiles.json updated\n` +
|
|
393
|
+
(r.mcpConfigPaths.length
|
|
394
|
+
? ` mcp: ${r.mcpConfigPaths.join(', ')} → ${r.daemonUrl}\n`
|
|
395
|
+
: r.codexMcpConfigPath
|
|
396
|
+
? ` mcp: ${r.codexMcpConfigPath} (codex, token-free dummy-bearer — send_to_peer ready)\n`
|
|
397
|
+
: ' mcp: (none — infra/router runtime)\n') +
|
|
398
|
+
` doctrine: ${r.doctrinePath}${r.doctrineCreated ? ' (template created — fill it in)' : ' (kept)'}\n` +
|
|
399
|
+
(r.plistPath ? ` plist: ${r.plistPath}\n` : '') +
|
|
400
|
+
`\nNext: fill in ${r.doctrinePath} and the peer's description, then launch with \`iapeer ${r.runtime}\`.\n`,
|
|
401
|
+
)
|
|
402
|
+
process.exit(0)
|
|
403
|
+
})
|
|
404
|
+
.catch(e => {
|
|
405
|
+
process.stderr.write(`init failed: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
406
|
+
process.exit(1)
|
|
407
|
+
})
|
|
408
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// init — per-peer onboarding: HTTP-MCP .mcp.json wiring (the install-gate-proven
|
|
2
|
+
// transport), doctrine template, and the initPeer orchestration over provisionPeer.
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
6
|
+
import { tmpdir } from 'os'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import {
|
|
9
|
+
CODEX_BEARER_ENV_VAR,
|
|
10
|
+
IAPEER_MCP_SERVER_NAME,
|
|
11
|
+
codexConfigPath,
|
|
12
|
+
ensureDoctrineTemplate,
|
|
13
|
+
initPeer,
|
|
14
|
+
resolveDaemonMcpUrl,
|
|
15
|
+
writeClaudeMcpConfig,
|
|
16
|
+
writeCodexMcpConfig,
|
|
17
|
+
} from './index.ts'
|
|
18
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
19
|
+
import { readPeerProfile } from '../identity/index.ts'
|
|
20
|
+
|
|
21
|
+
let root: string
|
|
22
|
+
let cwd: string
|
|
23
|
+
function cleanEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
24
|
+
const env: NodeJS.ProcessEnv = { ...process.env, IAPEER_ROOT: root, ...extra }
|
|
25
|
+
delete env.PEER_PERSONALITY
|
|
26
|
+
delete env.PEER_IDENTITY
|
|
27
|
+
delete env.PEER_RUNTIME
|
|
28
|
+
return env
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-init-root-'))
|
|
33
|
+
cwd = mkdtempSync(join(tmpdir(), 'iapeer-init-cwd-'))
|
|
34
|
+
})
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(root, { recursive: true, force: true })
|
|
37
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('resolveDaemonMcpUrl', () => {
|
|
41
|
+
test('no router.json → well-known default loopback URL (IAPEER_PORT respected)', () => {
|
|
42
|
+
expect(resolveDaemonMcpUrl({ env: cleanEnv() })).toBe('http://127.0.0.1:8765/mcp')
|
|
43
|
+
expect(resolveDaemonMcpUrl({ env: cleanEnv({ IAPEER_PORT: '9999' }) })).toBe('http://127.0.0.1:9999/mcp')
|
|
44
|
+
})
|
|
45
|
+
test('router.json present → its tcp field (daemon-published endpoint)', () => {
|
|
46
|
+
const stateDir = join(root, 'state', 'iapeer')
|
|
47
|
+
mkdirSync(stateDir, { recursive: true })
|
|
48
|
+
writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
|
|
49
|
+
expect(resolveDaemonMcpUrl({ env: cleanEnv() })).toBe('http://127.0.0.1:8765/mcp')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('writeClaudeMcpConfig', () => {
|
|
54
|
+
test('writes the iapeer http server with the X-IAPeer-Identity header', () => {
|
|
55
|
+
const path = writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
|
|
56
|
+
const doc = JSON.parse(readFileSync(path, 'utf8'))
|
|
57
|
+
expect(doc.mcpServers[IAPEER_MCP_SERVER_NAME]).toEqual({
|
|
58
|
+
type: 'http',
|
|
59
|
+
url: 'http://127.0.0.1:8765/mcp',
|
|
60
|
+
headers: { 'X-IAPeer-Identity': 'claude-boris' },
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
test('MERGES — preserves other mcpServers, only (re)writes iapeer', () => {
|
|
64
|
+
writeFileSync(join(cwd, '.mcp.json'), JSON.stringify({ mcpServers: { other: { type: 'http', url: 'https://x/mcp' } } }))
|
|
65
|
+
writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
|
|
66
|
+
const doc = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf8'))
|
|
67
|
+
expect(doc.mcpServers.other).toEqual({ type: 'http', url: 'https://x/mcp' })
|
|
68
|
+
expect(doc.mcpServers.iapeer.headers['X-IAPeer-Identity']).toBe('claude-boris')
|
|
69
|
+
})
|
|
70
|
+
test('idempotent — re-running yields the same bytes', () => {
|
|
71
|
+
const p = writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
|
|
72
|
+
const first = readFileSync(p, 'utf8')
|
|
73
|
+
writeClaudeMcpConfig(cwd, 'boris', 'http://127.0.0.1:8765/mcp')
|
|
74
|
+
expect(readFileSync(p, 'utf8')).toBe(first)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('ensureDoctrineTemplate', () => {
|
|
79
|
+
test('creates the template when absent', () => {
|
|
80
|
+
const { path, created } = ensureDoctrineTemplate(cwd)
|
|
81
|
+
expect(created).toBe(true)
|
|
82
|
+
expect(existsSync(path)).toBe(true)
|
|
83
|
+
expect(readFileSync(path, 'utf8')).toContain('Peer doctrine')
|
|
84
|
+
})
|
|
85
|
+
test('never overwrites an existing doctrine', () => {
|
|
86
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
87
|
+
writeFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'I am boris.')
|
|
88
|
+
const { created } = ensureDoctrineTemplate(cwd)
|
|
89
|
+
expect(created).toBe(false)
|
|
90
|
+
expect(readFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'utf8')).toBe('I am boris.')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('writeCodexMcpConfig (token-free recipe — dummy bearer + env_http_headers identity)', () => {
|
|
95
|
+
test('adds [mcp_servers.iapeer] with url + approve + bearer_token_env_var + env_http_headers', () => {
|
|
96
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome-'))
|
|
97
|
+
try {
|
|
98
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
|
|
99
|
+
const { path, added } = writeCodexMcpConfig('http://127.0.0.1:8765/mcp', { env })
|
|
100
|
+
expect(added).toBe(true)
|
|
101
|
+
expect(path).toBe(codexConfigPath({ env }))
|
|
102
|
+
const toml = readFileSync(path, 'utf8')
|
|
103
|
+
expect(toml).toContain('[mcp_servers.iapeer]')
|
|
104
|
+
expect(toml).toContain('url = "http://127.0.0.1:8765/mcp"')
|
|
105
|
+
expect(toml).toContain('default_tools_approval_mode = "approve"')
|
|
106
|
+
expect(toml).toContain(`bearer_token_env_var = "${CODEX_BEARER_ENV_VAR}"`)
|
|
107
|
+
expect(toml).toContain('[mcp_servers.iapeer.env_http_headers]')
|
|
108
|
+
expect(toml).toContain('"X-IAPeer-Identity" = "PEER_IDENTITY"')
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(codexHome, { recursive: true, force: true })
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
test('idempotent — re-run does NOT duplicate the block, and preserves other config', () => {
|
|
114
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome2-'))
|
|
115
|
+
try {
|
|
116
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
|
|
117
|
+
writeFileSync(codexConfigPath({ env }), '[other]\nkeep = true\n')
|
|
118
|
+
expect(writeCodexMcpConfig('http://x/mcp', { env }).added).toBe(true)
|
|
119
|
+
expect(writeCodexMcpConfig('http://x/mcp', { env }).added).toBe(false) // idempotent
|
|
120
|
+
const toml = readFileSync(codexConfigPath({ env }), 'utf8')
|
|
121
|
+
expect((toml.match(/\[mcp_servers\.iapeer\]/g) ?? []).length).toBe(1) // no duplicate
|
|
122
|
+
expect(toml).toContain('[other]') // other config preserved
|
|
123
|
+
expect(toml).toContain('keep = true')
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(codexHome, { recursive: true, force: true })
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('initPeer orchestration', () => {
|
|
131
|
+
test('codex peer: profile + registry + token-free codex config + doctrine', async () => {
|
|
132
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome3-'))
|
|
133
|
+
try {
|
|
134
|
+
const env = cleanEnv({ CODEX_HOME: codexHome })
|
|
135
|
+
const r = await initPeer({ cwd, runtime: 'codex', personality: 'cpeer', env })
|
|
136
|
+
expect(readPeerProfile(cwd)?.personality).toBe('cpeer')
|
|
137
|
+
expect(r.mcpConfigPaths.length).toBe(0) // no claude .mcp.json for a codex peer
|
|
138
|
+
expect(r.codexMcpConfigPath).toBe(codexConfigPath({ env }))
|
|
139
|
+
const toml = readFileSync(r.codexMcpConfigPath!, 'utf8')
|
|
140
|
+
expect(toml).toContain('[mcp_servers.iapeer]')
|
|
141
|
+
expect(toml).toContain(`bearer_token_env_var = "${CODEX_BEARER_ENV_VAR}"`) // token-free recipe wired
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(codexHome, { recursive: true, force: true })
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('claude peer: profile + registry + .mcp.json + doctrine, all wired', async () => {
|
|
148
|
+
const env = cleanEnv()
|
|
149
|
+
const r = await initPeer({ cwd, runtime: 'claude', personality: 'mypeer', env })
|
|
150
|
+
// identity + registry (via provision)
|
|
151
|
+
expect(readPeerProfile(cwd)?.personality).toBe('mypeer')
|
|
152
|
+
expect(readPeersIndex({ env }).peers.some(p => p.personality === 'mypeer')).toBe(true)
|
|
153
|
+
// .mcp.json wired to the daemon with the per-peer identity header
|
|
154
|
+
expect(r.mcpConfigPaths.length).toBe(1)
|
|
155
|
+
const mcp = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf8'))
|
|
156
|
+
expect(mcp.mcpServers.iapeer.headers['X-IAPeer-Identity']).toBe('claude-mypeer')
|
|
157
|
+
expect(mcp.mcpServers.iapeer.url).toBe('http://127.0.0.1:8765/mcp')
|
|
158
|
+
// doctrine template created
|
|
159
|
+
expect(r.doctrineCreated).toBe(true)
|
|
160
|
+
expect(existsSync(r.doctrinePath)).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('idempotent — re-init does not throw and keeps an existing doctrine', async () => {
|
|
164
|
+
const env = cleanEnv()
|
|
165
|
+
await initPeer({ cwd, runtime: 'claude', personality: 'mypeer', env })
|
|
166
|
+
writeFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'custom doctrine')
|
|
167
|
+
const r2 = await initPeer({ cwd, runtime: 'claude', personality: 'mypeer', env })
|
|
168
|
+
expect(r2.doctrineCreated).toBe(false)
|
|
169
|
+
expect(readFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'utf8')).toBe('custom doctrine')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// resolvePrimaryRuntime — the runtime-aware, CONSISTENT primary-runtime resolution
|
|
2
|
+
// for init/create. Pure (filesystem markers only); no registry / launchd touched.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'fs'
|
|
6
|
+
import { tmpdir } from 'os'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { resolvePrimaryRuntime } from './index.ts'
|
|
9
|
+
|
|
10
|
+
const dirs: string[] = []
|
|
11
|
+
function mkTmp(): string {
|
|
12
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-rt-'))
|
|
13
|
+
dirs.push(d)
|
|
14
|
+
return d
|
|
15
|
+
}
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('resolvePrimaryRuntime', () => {
|
|
21
|
+
test('explicit runtime wins regardless of markers', () => {
|
|
22
|
+
const cwd = mkTmp()
|
|
23
|
+
mkdirSync(join(cwd, '.codex'))
|
|
24
|
+
expect(resolvePrimaryRuntime(cwd, 'telegram')).toBe('telegram')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('no marker → claude (default)', () => {
|
|
28
|
+
expect(resolvePrimaryRuntime(mkTmp())).toBe('claude')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('.codex-only folder → codex primary (was the inconsistency: defaulted to claude)', () => {
|
|
32
|
+
const cwd = mkTmp()
|
|
33
|
+
mkdirSync(join(cwd, '.codex'))
|
|
34
|
+
expect(resolvePrimaryRuntime(cwd)).toBe('codex')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('.claude folder → claude', () => {
|
|
38
|
+
const cwd = mkTmp()
|
|
39
|
+
mkdirSync(join(cwd, '.claude'))
|
|
40
|
+
expect(resolvePrimaryRuntime(cwd)).toBe('claude')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('both markers → claude primary (agentic default order)', () => {
|
|
44
|
+
const cwd = mkTmp()
|
|
45
|
+
mkdirSync(join(cwd, '.claude'))
|
|
46
|
+
mkdirSync(join(cwd, '.codex'))
|
|
47
|
+
expect(resolvePrimaryRuntime(cwd)).toBe('claude')
|
|
48
|
+
})
|
|
49
|
+
})
|