@agfpd/iapeer 0.2.14 → 0.2.16
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/cli.test.ts +20 -2
- package/src/cli/index.ts +44 -1
- package/src/launch/adapters/claude.ts +18 -8
- package/src/launch/launch.test.ts +19 -6
- package/src/lifecycle/index.ts +36 -9
- package/src/lifecycle/lifecycle.test.ts +31 -0
- package/src/onboard/steps.test.ts +201 -0
- package/src/onboard/steps.ts +246 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// persistent-peer launchd plist must be refused.
|
|
5
5
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
7
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
|
-
import { isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
|
|
12
|
+
import { hasIdleReaped, isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
14
14
|
|
|
15
15
|
let root: string
|
|
@@ -73,6 +73,24 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
|
|
|
73
73
|
test('stop an unregistered peer → throws', () => {
|
|
74
74
|
expect(() => stopPeer('nobody', undefined, { env: env() })).toThrow(/not registered/)
|
|
75
75
|
})
|
|
76
|
+
test('stop is a CLEAN PARK: leaves the resume marker and drops the session-state (boris 10.06)', async () => {
|
|
77
|
+
await register('parked')
|
|
78
|
+
const e = env()
|
|
79
|
+
const cfg = loadLifecycleConfig(e)
|
|
80
|
+
// a live session-state the daemon would otherwise tag as a death post-kill
|
|
81
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
82
|
+
writeFileSync(
|
|
83
|
+
join(cfg.stateDir, 'claude-parked.session'),
|
|
84
|
+
JSON.stringify({ identity: 'claude-parked', runtime: 'claude', personality: 'parked', cwd: '/tmp/parked', wokeAt: Date.now() }),
|
|
85
|
+
)
|
|
86
|
+
stopPeer('parked', undefined, { env: e })
|
|
87
|
+
expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true) // park marker → post-start wake RESUMES
|
|
88
|
+
expect(existsSync(join(cfg.stateDir, 'claude-parked.session'))).toBe(false) // not a death for supervise
|
|
89
|
+
// start clears only the stop flag — the park marker survives for the wake
|
|
90
|
+
startPeer('parked', undefined, { env: e })
|
|
91
|
+
expect(isStopped(cfg, 'claude-parked')).toBe(false)
|
|
92
|
+
expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
|
|
93
|
+
})
|
|
76
94
|
})
|
|
77
95
|
|
|
78
96
|
describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
isStopped,
|
|
34
34
|
killSession,
|
|
35
35
|
loadLifecycleConfig,
|
|
36
|
+
removeSessionState,
|
|
37
|
+
setIdleReaped,
|
|
36
38
|
setNewEager,
|
|
37
39
|
setStopped,
|
|
38
40
|
wakeOrSpawn,
|
|
@@ -196,8 +198,15 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
196
198
|
killSession(sock, identity)
|
|
197
199
|
out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
|
|
198
200
|
} else {
|
|
201
|
+
// A deliberate stop is a CLEAN PARK, not a death (boris 10.06 — stop→start
|
|
202
|
+
// must survive ≥ idle-reap): park-mark BEFORE the kill so the post-`start`
|
|
203
|
+
// wake RESUMES, and drop the supervise session-state with the session so
|
|
204
|
+
// the tick never tags this kill as a death (crash-loop ring stays clean,
|
|
205
|
+
// no reaped-gone death class for a state the daemon knows 100%).
|
|
199
206
|
setStopped(cfg, identity)
|
|
207
|
+
setIdleReaped(cfg, identity)
|
|
200
208
|
killSession(sock, identity)
|
|
209
|
+
removeSessionState(cfg, identity)
|
|
201
210
|
out.push({ personality, runtime: rt, action: 'stopped' })
|
|
202
211
|
}
|
|
203
212
|
}
|
|
@@ -359,7 +368,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
359
368
|
version | --version | -v print the installed binary's version
|
|
360
369
|
help | --help | -h print this usage (works appended to any verb; executes nothing)
|
|
361
370
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
362
|
-
onboard [--dry-run] [--
|
|
371
|
+
onboard [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
|
|
372
|
+
backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
|
|
363
373
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
364
374
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
365
375
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
@@ -410,6 +420,39 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
410
420
|
out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
|
|
411
421
|
let infraFailed = false
|
|
412
422
|
const infra = typeof flags.infra === 'string' ? flags.infra.split(',').map(s => s.trim()).filter(Boolean) : []
|
|
423
|
+
// Backbone default-yes steps (design «Onboard костяка», FINAL 10.06). ORDER is
|
|
424
|
+
// significant: notifier → TELEGRAM (creates the human peer) → memory below
|
|
425
|
+
// (its --human resolves from the natural peer that just appeared). Each step
|
|
426
|
+
// is soft-skip on unavailability; only a REAL deploy/create break fails.
|
|
427
|
+
const { onboardNotifierStep, onboardTelegramStep } = await import('./../onboard/steps.ts')
|
|
428
|
+
// An explicit `--infra notifier` takes the fail-closed explicit path below —
|
|
429
|
+
// the default-yes (soft-skip) step then stands aside to avoid double-deploy.
|
|
430
|
+
const ns = await onboardNotifierStep({
|
|
431
|
+
skip: flags['no-notifier'] === true || infra.includes('notifier'),
|
|
432
|
+
dryRun: flags['dry-run'] === true,
|
|
433
|
+
env,
|
|
434
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
435
|
+
})
|
|
436
|
+
if (ns.state !== 'skipped-flag') {
|
|
437
|
+
const peersLine = ns.peers.length ? ` — ${ns.peers.map(p => `${p.personality} (bootstrap ${p.bootstrap ?? 'n/a'})`).join(', ')}` : ''
|
|
438
|
+
out(`notifier: ${ns.state}${ns.detail ? ` — ${ns.detail}` : ''}${peersLine}\n`)
|
|
439
|
+
if (ns.state === 'deploy-failed') infraFailed = true
|
|
440
|
+
}
|
|
441
|
+
const ts = await onboardTelegramStep({
|
|
442
|
+
skip: flags['no-telegram'] === true,
|
|
443
|
+
human: typeof flags['telegram-human'] === 'string' ? flags['telegram-human'] : undefined,
|
|
444
|
+
userId: typeof flags['telegram-user-id'] === 'string' ? flags['telegram-user-id'] : undefined,
|
|
445
|
+
dryRun: flags['dry-run'] === true,
|
|
446
|
+
env,
|
|
447
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
448
|
+
})
|
|
449
|
+
if (ts.state !== 'skipped-flag') {
|
|
450
|
+
const line = `telegram: ${ts.state}${ts.personality ? ` — ${ts.personality}` : ''}${ts.detail ? ` — ${ts.detail}` : ''}\n`
|
|
451
|
+
// a refusal/failure goes to stderr (loud), the rest to stdout
|
|
452
|
+
if (ts.state === 'refused-non-tty' || ts.state === 'invalid-input' || ts.state === 'create-failed') errOut(line)
|
|
453
|
+
else out(line)
|
|
454
|
+
if (ts.state === 'invalid-input' || ts.state === 'create-failed') infraFailed = true
|
|
455
|
+
}
|
|
413
456
|
if (infra.length && flags['dry-run'] !== true) {
|
|
414
457
|
const { onboardRuntime } = await import('../runtime/deploy.ts')
|
|
415
458
|
for (const rt of infra) {
|
|
@@ -39,7 +39,8 @@ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, Runt
|
|
|
39
39
|
* (claude-start.sh:337), shown when PEER_START_ARGS carries
|
|
40
40
|
* --dangerously-load-development-channels.
|
|
41
41
|
* - 'Resume from summary' — the resume compact-picker (default cursor =
|
|
42
|
-
* "summary (recommended)", which compacts; bootDialogKeys
|
|
42
|
+
* "summary (recommended)", which compacts; bootDialogKeys steps the cursor to
|
|
43
|
+
* "full" ONE key per iteration and confirms only after SEEING it there).
|
|
43
44
|
* - 'Resuming the full session' — the post-select load state (still not ready).
|
|
44
45
|
*/
|
|
45
46
|
const CLAUDE_BOOT_DIALOG_MARKERS = [
|
|
@@ -146,21 +147,30 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
146
147
|
/**
|
|
147
148
|
* Map a visible startup dialog to the keys that clear it correctly:
|
|
148
149
|
*
|
|
149
|
-
* - RESUME COMPACT-PICKER ('Resume from summary …') →
|
|
150
|
+
* - RESUME COMPACT-PICKER ('Resume from summary …') → CURSOR-VERIFIED two-step.
|
|
150
151
|
* This menu's cursor DEFAULTS to "1. Resume from summary (recommended)",
|
|
151
152
|
* and selecting it COMPACTS the session (claude implements that choice as an
|
|
152
|
-
* internal /compact)
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
153
|
+
* internal /compact) — the owner's invariant is «на resume не должно быть
|
|
154
|
+
* компакта». The original blind ['Down','Enter'] burst (0e67e1f) put BOTH
|
|
155
|
+
* keys into one pty chunk; under a heavy resume (boris 10.06, 313k-token
|
|
156
|
+
* session) the TUI dropped the Down and the Enter confirmed the DEFAULT →
|
|
157
|
+
* silent /compact (proven: pane log shows the picker frame with ❯ on
|
|
158
|
+
* option 1 followed by "❯ /compact"; transcript compactMetadata
|
|
159
|
+
* trigger=manual). Fix: ONE key per boot-iteration — Down while the cursor
|
|
160
|
+
* is NOT yet seen on "2. Resume full session", Enter ONLY after the
|
|
161
|
+
* captured pane PROVES it ("❯ 2."). A swallowed Down self-heals on the
|
|
162
|
+
* next 2 s iteration; Enter can never hit the compacting default. If the
|
|
163
|
+
* picker layout ever changes, the loop Downs until the boot deadline and
|
|
164
|
+
* the wake fails LOUD — never a silent compact.
|
|
157
165
|
* - OTHER MODALS (folder-trust / external-import / dev-channels) → ['Enter']:
|
|
158
166
|
* their default-highlighted option IS the proceed path, so a bare Enter clears
|
|
159
167
|
* each (claude-start.sh:341).
|
|
160
168
|
* - anything else (incl. the post-select "Resuming…" load state) → null (wait).
|
|
161
169
|
*/
|
|
162
170
|
bootDialogKeys(pane: string): string[] | null {
|
|
163
|
-
if (pane.includes('Resume from summary'))
|
|
171
|
+
if (pane.includes('Resume from summary')) {
|
|
172
|
+
return /❯\s*2\./.test(pane) ? ['Enter'] : ['Down']
|
|
173
|
+
}
|
|
164
174
|
if (
|
|
165
175
|
pane.includes('trust this folder') ||
|
|
166
176
|
pane.includes('Allow external CLAUDE.md file imports?') ||
|
|
@@ -172,12 +172,25 @@ describe('claudeAdapter', () => {
|
|
|
172
172
|
expect(claudeAdapter.bootDialogKeys('❯ ready')).toBeNull()
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
-
test('bootDialogKeys: resume compact-picker
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
).
|
|
175
|
+
test('bootDialogKeys: resume compact-picker is CURSOR-VERIFIED — Enter ONLY after ❯ is seen on "2. full"', () => {
|
|
176
|
+
// Regression (boris 10.06, 313k resume): the blind ['Down','Enter'] burst lost the
|
|
177
|
+
// Down in one pty chunk and Enter confirmed the DEFAULT "1. Resume from summary"
|
|
178
|
+
// → silent /compact. Owner's invariant: NO compact on resume. One key per boot
|
|
179
|
+
// iteration; confirm only on a PROVEN cursor position.
|
|
180
|
+
const cursorOn1 = '❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is'
|
|
181
|
+
const cursorOn2 = ' 1. Resume from summary (recommended)\n❯ 2. Resume full session as-is'
|
|
182
|
+
// cursor on the compacting default → step down, do NOT confirm
|
|
183
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn1)).toEqual(['Down'])
|
|
184
|
+
// a swallowed Down self-heals: the unchanged pane just gets another Down
|
|
185
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn1)).toEqual(['Down'])
|
|
186
|
+
// cursor PROVEN on "2. Resume full session" → now (and only now) confirm
|
|
187
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn2)).toEqual(['Enter'])
|
|
188
|
+
// Enter can never reach the compacting default: option-1 cursor never maps to Enter
|
|
189
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn1)).not.toContain('Enter')
|
|
190
|
+
// the 2.1.170 live layout (3rd item present) behaves the same
|
|
191
|
+
const live170 =
|
|
192
|
+
"This session is 7h 3m old and 314.2k tokens.\n❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is\n 3. Don't ask me again\nEnter to confirm · Esc to cancel"
|
|
193
|
+
expect(claudeAdapter.bootDialogKeys(live170)).toEqual(['Down'])
|
|
181
194
|
})
|
|
182
195
|
|
|
183
196
|
test('permissionDialog: proceed prompt → active, [Enter]', () => {
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -148,7 +148,10 @@ function writeSessionState(cfg: LifecycleConfig, state: SessionState): void {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
/** Drop the supervise session-state. Exported for the STOP verb: a deliberate stop
|
|
152
|
+
* removes the state with the session so superviseTick never tags the kill as a
|
|
153
|
+
* death (no crash-loop entry, no reaped-gone death class). */
|
|
154
|
+
export function removeSessionState(cfg: LifecycleConfig, identity: string): void {
|
|
152
155
|
try {
|
|
153
156
|
rmSync(sessionStatePath(cfg, identity), { force: true })
|
|
154
157
|
} catch {
|
|
@@ -198,10 +201,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
|
|
|
198
201
|
// Lifecycle markers — the DAEMON decides fresh-vs-resume by the DEATH CAUSE it
|
|
199
202
|
// tracks itself (TARGET redesign). Plain files in state/lifecycle/<identity>.* :
|
|
200
203
|
//
|
|
201
|
-
// .idle-reaped : written
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
204
|
+
// .idle-reaped : the CLEAN-PARK marker — written when the daemon idle-reaps the
|
|
205
|
+
// session AND by the deliberate `stop` verb (both are daemon/operator-initiated
|
|
206
|
+
// parks, not faults; boris 10.06: stop→start must survive ≥ idle-reap).
|
|
207
|
+
// Presence on the next wake = session was parked cleanly = RESUME-eligible.
|
|
208
|
+
// ABSENT on a dead session = it died on its own (crash / self-close) = FRESH.
|
|
209
|
+
// (resolver branch 3.)
|
|
205
210
|
// .new-eager : set when /new is invoked (owner reset, via `iapeer self-fresh`).
|
|
206
211
|
// Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
|
|
207
212
|
// wait for a message) and injects initial_prompt. Consumed on the relaunch.
|
|
@@ -230,7 +235,9 @@ export function hasIdleReaped(cfg: LifecycleConfig, identity: string): boolean {
|
|
|
230
235
|
return existsSync(idleReapedPath(cfg, identity))
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
/** Write the
|
|
238
|
+
/** Write the clean-park marker. Three writers, all deliberate parks: the idle-reap
|
|
239
|
+
* path in superviseTick, the `stop` verb (park before kill), and superviseTick's
|
|
240
|
+
* stopped-catch-up branch (a stop that raced the tick). Never a crash path. */
|
|
234
241
|
export function setIdleReaped(cfg: LifecycleConfig, identity: string): void {
|
|
235
242
|
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
236
243
|
writeFileSync(idleReapedPath(cfg, identity), `${new Date().toISOString()}\n`, { mode: 0o600 })
|
|
@@ -462,8 +469,15 @@ export function resolveWakeMode(
|
|
|
462
469
|
clearIdleReaped(cfg, identity)
|
|
463
470
|
return { resume: false, cause: 'ephemeral-policy' }
|
|
464
471
|
}
|
|
465
|
-
// 3a. NOT
|
|
466
|
-
|
|
472
|
+
// 3a. NOT parked-clean → FRESH either way, but tell the two cases apart in the
|
|
473
|
+
// cause (boris finding, 10.06): a peer with NO transcript at all never ran here —
|
|
474
|
+
// that is its FIRST-ever wake, not a crash. (A peer whose transcripts were rotated
|
|
475
|
+
// away also reads first-wake — equally honest: there is nothing it could resume.)
|
|
476
|
+
if (!hasIdleReaped(cfg, identity)) {
|
|
477
|
+
return resolveResume(cwd).ok
|
|
478
|
+
? { resume: false, cause: 'crash-or-self-close' }
|
|
479
|
+
: { resume: false, cause: 'first-wake' }
|
|
480
|
+
}
|
|
467
481
|
// 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
|
|
468
482
|
clearIdleReaped(cfg, identity)
|
|
469
483
|
// human-conversational dialogue never auto-freshes; only an explicit /new resets it.
|
|
@@ -967,7 +981,7 @@ export function killSession(sock: string, identity: string): void {
|
|
|
967
981
|
|
|
968
982
|
export interface SuperviseOutcome {
|
|
969
983
|
identity: string
|
|
970
|
-
action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'alive' | 'needs-eager-fresh'
|
|
984
|
+
action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh'
|
|
971
985
|
reason?: string
|
|
972
986
|
/** For 'needs-eager-fresh': the peer to EAGERLY re-launch fresh (its session died
|
|
973
987
|
* carrying a .new-eager mark). The daemon timer drives the async relaunch.
|
|
@@ -1002,6 +1016,19 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1002
1016
|
}
|
|
1003
1017
|
const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
|
|
1004
1018
|
if (!sessionAlive(sock, s.identity)) {
|
|
1019
|
+
// A DELIBERATE stop is not a death (boris 10.06): the stop verb parks clean
|
|
1020
|
+
// (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
|
|
1021
|
+
// a stop that raced this tick (or a pre-fix stop): drop the state quietly —
|
|
1022
|
+
// no crash-loop entry, no death class — and ensure the clean-park marker so
|
|
1023
|
+
// the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
|
|
1024
|
+
if (isStopped(cfg, s.identity)) {
|
|
1025
|
+
removeSessionState(cfg, s.identity)
|
|
1026
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1027
|
+
setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
|
|
1028
|
+
out.push({ identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' })
|
|
1029
|
+
trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
|
|
1030
|
+
continue
|
|
1031
|
+
}
|
|
1005
1032
|
// A dead session: record a death for crash-loop accounting, then branch on the
|
|
1006
1033
|
// .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
|
|
1007
1034
|
// the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
|
|
@@ -218,6 +218,24 @@ describe('superviseTick H4 guard', () => {
|
|
|
218
218
|
expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
test('a STOPPED peer with a dead session → skipped-stopped: no death record, park marker ensured', () => {
|
|
222
|
+
// boris repro (10.06): `iapeer stop` → next tick used to tag reaped-gone
|
|
223
|
+
// death=server-dead + recordDeath → post-start wake came up FRESH. A deliberate
|
|
224
|
+
// stop is a clean park the daemon knows 100% — never a death.
|
|
225
|
+
const c = cfg()
|
|
226
|
+
const id = writeState('iapeer-supstopped')
|
|
227
|
+
setStopped(c, id)
|
|
228
|
+
const out = superviseTick(c, { env: env(), nowMs: Date.now() })
|
|
229
|
+
const o = out.find(x => x.identity === id)
|
|
230
|
+
expect(o?.action).toBe('skipped-stopped')
|
|
231
|
+
expect(existsSync(join(stateDir, `${id}.session`))).toBe(false) // state dropped quietly
|
|
232
|
+
expect(readDeaths(c, id).length).toBe(0) // NOT a death — crash-loop ring untouched
|
|
233
|
+
expect(hasIdleReaped(c, id)).toBe(true) // clean park → post-start wake resumes
|
|
234
|
+
const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
235
|
+
expect(logged).toContain(`identity=${id} action=skipped-stopped`)
|
|
236
|
+
expect(logged).not.toContain('death=') // no death class for a deliberate stop
|
|
237
|
+
})
|
|
238
|
+
|
|
221
239
|
test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
|
|
222
240
|
const c = cfg()
|
|
223
241
|
const id = writeState('iapeer-supgone') // no plist, no live tmux session
|
|
@@ -433,6 +451,19 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
|
433
451
|
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
|
|
434
452
|
})
|
|
435
453
|
|
|
454
|
+
test('DEFAULT + NOT idle-reaped + NO transcript at all → FRESH with cause=first-wake (not a crash)', () => {
|
|
455
|
+
// boris finding (10.06): the first-ever wake of a freshly created peer used to
|
|
456
|
+
// read crash-or-self-close — a mis-classification. No transcript = never ran.
|
|
457
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, noTranscript)).toEqual({ resume: false, cause: 'first-wake' })
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ── stop→start is a CLEAN PARK (boris 10.06): the park marker → RESUME ────────
|
|
461
|
+
test('stop→start path: clean-park marker set by stop → the next default wake RESUMES', () => {
|
|
462
|
+
const c = cfg()
|
|
463
|
+
setIdleReaped(c, 'claude-p') // what the stop verb writes before killing
|
|
464
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
|
|
465
|
+
})
|
|
466
|
+
|
|
436
467
|
// ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
|
|
437
468
|
test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
|
|
438
469
|
const c = cfg()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Backbone default-yes onboard steps (design «Onboard костяка» FINAL 10.06) —
|
|
2
|
+
// notifier (zero questions, soft-skip on unavailability) and telegram (human peer
|
|
3
|
+
// via TELEGRAM_USER_ID env → the package's self-config hook; idempotent vs an
|
|
4
|
+
// existing natural peer). All sandboxed: IAPEER_ROOT / IAPEER_LAUNCHAGENTS_DIR
|
|
5
|
+
// temp dirs, IAPEER_TEST_SANDBOX skips real launchctl, injected npx/ask.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { findNaturalPeer, onboardNotifierStep, onboardTelegramStep } from './steps.ts'
|
|
12
|
+
import { writeRuntimeManifest, type RuntimeManifest } from '../runtime/index.ts'
|
|
13
|
+
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
14
|
+
|
|
15
|
+
const roots: string[] = []
|
|
16
|
+
function mkTmp(): string {
|
|
17
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-steps-'))
|
|
18
|
+
roots.push(d)
|
|
19
|
+
return d
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function envFor(root: string, path?: string): NodeJS.ProcessEnv {
|
|
26
|
+
return {
|
|
27
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
28
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
|
|
29
|
+
IAPEER_TEST_SANDBOX: '1',
|
|
30
|
+
HOME: root,
|
|
31
|
+
...(path ? { PATH: path } : {}),
|
|
32
|
+
} as NodeJS.ProcessEnv
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stub launcher + recording self-config hook on a PATH dir. */
|
|
36
|
+
function stubBins(bin: string): { dir: string; hook: string } {
|
|
37
|
+
const dir = mkTmp()
|
|
38
|
+
writeFileSync(join(dir, bin), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
|
|
39
|
+
const hook = join(dir, 'sc.sh')
|
|
40
|
+
// records personality + the TELEGRAM_USER_ID it received → proves env passthrough
|
|
41
|
+
writeFileSync(
|
|
42
|
+
hook,
|
|
43
|
+
'#!/bin/sh\nprintf "%s|%s" "$IAPEER_PEER_PERSONALITY" "$TELEGRAM_USER_ID" > "$IAPEER_ROOT/sc-$IAPEER_PEER_PERSONALITY"\nexit 0\n',
|
|
44
|
+
{ mode: 0o755 },
|
|
45
|
+
)
|
|
46
|
+
return { dir, hook }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('onboardNotifierStep (а — default-yes, zero questions)', () => {
|
|
50
|
+
test('npx self-deploy → declared set provisioned', async () => {
|
|
51
|
+
const root = mkTmp()
|
|
52
|
+
const { dir, hook } = stubBins('notifier-runtime')
|
|
53
|
+
const env = envFor(root, dir)
|
|
54
|
+
const runNpx = (_pkg: string, e: NodeJS.ProcessEnv) => {
|
|
55
|
+
const m: RuntimeManifest = {
|
|
56
|
+
runtime: 'notifier',
|
|
57
|
+
selfConfig: hook,
|
|
58
|
+
peers: [
|
|
59
|
+
{ personality: 'timer', intelligence: 'absent' },
|
|
60
|
+
{ personality: 'watcher', intelligence: 'absent' },
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
writeRuntimeManifest(m, { env: e })
|
|
64
|
+
return { ok: true }
|
|
65
|
+
}
|
|
66
|
+
const r = await onboardNotifierStep({ env, runNpx })
|
|
67
|
+
expect(r.state).toBe('deployed')
|
|
68
|
+
expect(r.peers.map(p => p.personality).sort()).toEqual(['timer', 'watcher'])
|
|
69
|
+
expect(findPeer(readPeersIndex({ env }), 'timer')).not.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('npx failure (no bun / unpublished / no network) → SOFT skip, not a failure', async () => {
|
|
73
|
+
const env = envFor(mkTmp())
|
|
74
|
+
const r = await onboardNotifierStep({ env, runNpx: () => ({ ok: false, detail: 'bun: command not found' }) })
|
|
75
|
+
expect(r.state).toBe('skipped-unavailable')
|
|
76
|
+
expect(r.detail).toContain('bun: command not found')
|
|
77
|
+
expect(r.detail).toContain('install later')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('package installed but manifest missing → deploy-failed (REAL break, not unavailability)', async () => {
|
|
81
|
+
const env = envFor(mkTmp())
|
|
82
|
+
// npx "succeeds" but self-deploys nothing — the package broke its contract
|
|
83
|
+
const r = await onboardNotifierStep({ env, runNpx: () => ({ ok: true }) })
|
|
84
|
+
expect(r.state).toBe('deploy-failed')
|
|
85
|
+
expect(r.detail).toContain('manifest')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('--no-notifier → skipped-flag; dry-run reports intent without touching anything', async () => {
|
|
89
|
+
const env = envFor(mkTmp())
|
|
90
|
+
expect((await onboardNotifierStep({ skip: true, env })).state).toBe('skipped-flag')
|
|
91
|
+
const dry = await onboardNotifierStep({ dryRun: true, env })
|
|
92
|
+
expect(dry.state).toBe('dry-run')
|
|
93
|
+
expect(dry.detail).toContain('@agfpd/notifier-runtime')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('idempotent re-run: manifest present → install skipped, deploy no-clobber', async () => {
|
|
97
|
+
const root = mkTmp()
|
|
98
|
+
const { dir, hook } = stubBins('notifier-runtime')
|
|
99
|
+
const env = envFor(root, dir)
|
|
100
|
+
writeRuntimeManifest(
|
|
101
|
+
{ runtime: 'notifier', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
|
|
102
|
+
{ env },
|
|
103
|
+
)
|
|
104
|
+
let npxCalled = false
|
|
105
|
+
const first = await onboardNotifierStep({ env, runNpx: () => ((npxCalled = true), { ok: true }) })
|
|
106
|
+
expect(first.state).toBe('deployed')
|
|
107
|
+
expect(npxCalled).toBe(false) // manifest-gated — npx never re-ran
|
|
108
|
+
const second = await onboardNotifierStep({ env, runNpx: () => ({ ok: true }) })
|
|
109
|
+
expect(second.state).toBe('deployed') // no-clobber re-verify
|
|
110
|
+
expect(readPeersIndex({ env }).peers.filter(p => p.personality === 'timer').length).toBe(1)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('onboardTelegramStep (б — human peer, идемпотентность, non-tty)', () => {
|
|
115
|
+
test('flags path: creates the human peer; TELEGRAM_USER_ID reaches the self-config hook', async () => {
|
|
116
|
+
const root = mkTmp()
|
|
117
|
+
const { dir, hook } = stubBins('telegram-runtime')
|
|
118
|
+
const env = envFor(root, dir)
|
|
119
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: hook }, { env }) // installed (mode b — no declared peers)
|
|
120
|
+
const r = await onboardTelegramStep({ human: 'Arthur', userId: '123456789', env })
|
|
121
|
+
expect(r.state).toBe('created')
|
|
122
|
+
expect(r.personality).toBe('arthur') // normalized
|
|
123
|
+
const peer = findPeer(readPeersIndex({ env }), 'arthur')!
|
|
124
|
+
expect(peer.intelligence).toBe('natural')
|
|
125
|
+
expect(peer.runtime).toBe('telegram')
|
|
126
|
+
// the hook saw the user id via env (owner's contract: hook writes interfaces itself)
|
|
127
|
+
expect(readFileSync(join(env.IAPEER_ROOT as string, 'sc-arthur'), 'utf8')).toBe('arthur|123456789')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('IDEMPOTENT: an existing natural peer → already, NEVER a second human (boris check)', async () => {
|
|
131
|
+
const env = envFor(mkTmp())
|
|
132
|
+
await upsertPeer(
|
|
133
|
+
{ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' },
|
|
134
|
+
{ env },
|
|
135
|
+
)
|
|
136
|
+
let asked = false
|
|
137
|
+
const r = await onboardTelegramStep({
|
|
138
|
+
env,
|
|
139
|
+
ask: async () => ((asked = true), 'never'),
|
|
140
|
+
isTty: true,
|
|
141
|
+
})
|
|
142
|
+
expect(r.state).toBe('already')
|
|
143
|
+
expect(r.personality).toBe('arthur')
|
|
144
|
+
expect(r.detail).toContain('уже есть')
|
|
145
|
+
expect(asked).toBe(false) // no questions, no install — the WHOLE step no-ops
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('non-tty without flags → refusal of the STEP (with the flag recipe), not of onboard', async () => {
|
|
149
|
+
const root = mkTmp()
|
|
150
|
+
const env = envFor(root)
|
|
151
|
+
writeRuntimeManifest({ runtime: 'telegram' }, { env })
|
|
152
|
+
const r = await onboardTelegramStep({ env, isTty: false })
|
|
153
|
+
expect(r.state).toBe('refused-non-tty')
|
|
154
|
+
expect(r.detail).toContain('--telegram-human')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('tty prompt path: answers flow in; empty answer → soft refusal', async () => {
|
|
158
|
+
const root = mkTmp()
|
|
159
|
+
const { dir, hook } = stubBins('telegram-runtime')
|
|
160
|
+
const env = envFor(root, dir)
|
|
161
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: hook }, { env })
|
|
162
|
+
const answers = ['leo', '42']
|
|
163
|
+
const r = await onboardTelegramStep({ env, isTty: true, ask: async () => answers.shift() ?? '' })
|
|
164
|
+
expect(r.state).toBe('created')
|
|
165
|
+
expect(r.personality).toBe('leo')
|
|
166
|
+
// empty answer → not now
|
|
167
|
+
const env2 = envFor(mkTmp())
|
|
168
|
+
writeRuntimeManifest({ runtime: 'telegram' }, { env: env2 })
|
|
169
|
+
const r2 = await onboardTelegramStep({ env: env2, isTty: true, ask: async () => '' })
|
|
170
|
+
expect(r2.state).toBe('refused-non-tty')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('invalid explicit flags → invalid-input (hard): bad name / non-digit user id', async () => {
|
|
174
|
+
const root = mkTmp()
|
|
175
|
+
const env = envFor(root)
|
|
176
|
+
writeRuntimeManifest({ runtime: 'telegram' }, { env })
|
|
177
|
+
expect((await onboardTelegramStep({ human: '!!!', userId: '42', env })).state).toBe('invalid-input')
|
|
178
|
+
const r = await onboardTelegramStep({ human: 'leo', userId: 'abc', env })
|
|
179
|
+
expect(r.state).toBe('invalid-input')
|
|
180
|
+
expect(r.detail).toContain('@userinfobot')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('package unavailable → soft skip (step), with the install-later recipe', async () => {
|
|
184
|
+
const env = envFor(mkTmp())
|
|
185
|
+
const r = await onboardTelegramStep({
|
|
186
|
+
human: 'leo',
|
|
187
|
+
userId: '42',
|
|
188
|
+
env,
|
|
189
|
+
runNpx: () => ({ ok: false, detail: 'npm ERR 404' }),
|
|
190
|
+
})
|
|
191
|
+
expect(r.state).toBe('skipped-unavailable')
|
|
192
|
+
expect(r.detail).toContain('install-runtime telegram')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('findNaturalPeer: null on empty/no registry, found when present', async () => {
|
|
196
|
+
const env = envFor(mkTmp())
|
|
197
|
+
expect(findNaturalPeer(env)).toBeNull()
|
|
198
|
+
await upsertPeer({ personality: 'h', runtime: 'telegram', cwd: '/tmp/h', intelligence: 'natural' }, { env })
|
|
199
|
+
expect(findNaturalPeer(env)).toBe('h')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Default-yes onboard steps for the distribution BACKBONE (design doc
|
|
2
|
+
// docs/«Onboard костяка — notifier, telegram, каналы, update» — FINAL 10.06,
|
|
3
|
+
// sanctioned by Артур; owners' facts baked: notifier ack «ноль вопросов»,
|
|
4
|
+
// telegram self-config contract v0.10.0+).
|
|
5
|
+
//
|
|
6
|
+
// Backbone = core + memory slot + notifier-runtime + telegram-runtime. The onboard
|
|
7
|
+
// step ORDER is significant: marketplace → notifier → TELEGRAM (creates the human
|
|
8
|
+
// peer) → memory (its --human resolves from the natural peer that just appeared).
|
|
9
|
+
//
|
|
10
|
+
// UX principle («вопросами владеет рантайм»): the package owns its questions; the
|
|
11
|
+
// core orchestrates the steps, passes only facts IT owns, and inherits stdio for
|
|
12
|
+
// the package's own interactive. Every step is OPTIONAL (default-yes, removed by a
|
|
13
|
+
// flag); unavailability is a SOFT SKIP, never a core failure.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'child_process'
|
|
16
|
+
import { isValidName, normalizeIntelligenceValue, normalizeNameCandidate } from '../core/constants.ts'
|
|
17
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
18
|
+
import { createPeer } from '../create/index.ts'
|
|
19
|
+
import {
|
|
20
|
+
deployRuntime,
|
|
21
|
+
installRuntimePackage,
|
|
22
|
+
RUNTIME_PACKAGES,
|
|
23
|
+
type DeployedPeer,
|
|
24
|
+
type NpxRunner,
|
|
25
|
+
} from '../runtime/deploy.ts'
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// (а) notifier — default-yes, ZERO questions (owner-confirmed: the whole chain
|
|
29
|
+
// has no prompt/stdin/tty dependency; all failures are fail-closed exit≠0).
|
|
30
|
+
// Prereq (not a question): `bun` on PATH — without it the npx shim exits non-zero
|
|
31
|
+
// → SOFT SKIP with a clear message (design: unavailability is not a core error).
|
|
32
|
+
// Idempotent re-onboard: install is manifest-gated (skipped), deploy is no-clobber
|
|
33
|
+
// («1 плист = 1 infra-пир»).
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface NotifierStepOptions {
|
|
37
|
+
/** --no-notifier: remove the default-yes step. */
|
|
38
|
+
skip?: boolean
|
|
39
|
+
dryRun?: boolean
|
|
40
|
+
env?: NodeJS.ProcessEnv
|
|
41
|
+
/** Injected npx runner (tests / sandbox proof). */
|
|
42
|
+
runNpx?: NpxRunner
|
|
43
|
+
warn?: (message: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface NotifierStepResult {
|
|
47
|
+
state:
|
|
48
|
+
| 'deployed' // declared set provisioned (or re-verified — idempotent)
|
|
49
|
+
| 'skipped-flag' // --no-notifier
|
|
50
|
+
| 'skipped-unavailable' // npx failed (no bun / not published / no network) — soft skip
|
|
51
|
+
| 'deploy-failed' // the package installed but the declared-set deploy broke — REAL failure
|
|
52
|
+
| 'dry-run'
|
|
53
|
+
peers: DeployedPeer[]
|
|
54
|
+
detail?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function onboardNotifierStep(opts: NotifierStepOptions = {}): Promise<NotifierStepResult> {
|
|
58
|
+
const env = opts.env ?? process.env
|
|
59
|
+
if (opts.skip) return { state: 'skipped-flag', peers: [] }
|
|
60
|
+
if (opts.dryRun) {
|
|
61
|
+
return {
|
|
62
|
+
state: 'dry-run',
|
|
63
|
+
peers: [],
|
|
64
|
+
detail: `would npx-install ${RUNTIME_PACKAGES.notifier} + deploy its declared peer-set (timer, watcher)`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const install = installRuntimePackage({ runtime: 'notifier', env, runNpx: opts.runNpx })
|
|
68
|
+
if (install.state === 'failed' || install.state === 'no-package') {
|
|
69
|
+
// SOFT skip (design (а)): a missing prereq (bun) / unpublished package / no
|
|
70
|
+
// network must not fail the core's onboard — report and move on.
|
|
71
|
+
return {
|
|
72
|
+
state: 'skipped-unavailable',
|
|
73
|
+
peers: [],
|
|
74
|
+
detail: `${install.package ?? 'notifier package'} install failed (${install.detail?.split('\n')[0] ?? 'npx non-zero'}) — install later: iapeer install-runtime notifier`,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const deploy = await deployRuntime({ runtime: 'notifier', env, warn: opts.warn })
|
|
79
|
+
const broken = deploy.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed')
|
|
80
|
+
return {
|
|
81
|
+
state: broken ? 'deploy-failed' : 'deployed',
|
|
82
|
+
peers: deploy.peers,
|
|
83
|
+
detail: broken ? 'a declared peer failed self-config/bootstrap — see the per-peer lines' : undefined,
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
// The package installed but its manifest/deploy contract broke — a REAL failure
|
|
87
|
+
// (not unavailability), surfaced as such.
|
|
88
|
+
return { state: 'deploy-failed', peers: [], detail: e instanceof Error ? e.message : String(e) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// (б) telegram — default-yes, install WITH setup. The human owes exactly two
|
|
94
|
+
// facts: the human-peer name and their telegram user_id (prompt hints at
|
|
95
|
+
// @userinfobot). The core creates the HUMAN peer via createPeer with
|
|
96
|
+
// TELEGRAM_USER_ID in env — the package's self-config hook (contract v0.10.0+)
|
|
97
|
+
// writes interfaces.telegram.user_id itself; no prepare / interface-human calls.
|
|
98
|
+
// Bot tokens are NOT asked here — they are per-channel (`iapeer connect telegram`).
|
|
99
|
+
//
|
|
100
|
+
// IDEMPOTENT re-onboard (boris's check, baked into the design): an EXISTING
|
|
101
|
+
// natural peer in the registry → the step is a no-op/skip with a clear message —
|
|
102
|
+
// NEVER an offer to create a second human peer.
|
|
103
|
+
//
|
|
104
|
+
// Interactive lives INSIDE the step (tty only); non-tty without the flags
|
|
105
|
+
// (--telegram-human + --telegram-user-id) → an explicit refusal of the STEP, not
|
|
106
|
+
// of the whole onboard.
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface TelegramStepOptions {
|
|
110
|
+
/** --no-telegram: remove the default-yes step. */
|
|
111
|
+
skip?: boolean
|
|
112
|
+
/** --telegram-human <p>: the human peer's personality (skips the prompt). */
|
|
113
|
+
human?: string
|
|
114
|
+
/** --telegram-user-id <id>: the owner's telegram user id (skips the prompt). */
|
|
115
|
+
userId?: string
|
|
116
|
+
dryRun?: boolean
|
|
117
|
+
env?: NodeJS.ProcessEnv
|
|
118
|
+
runNpx?: NpxRunner
|
|
119
|
+
/** Injectable prompt (tests). Default: readline on the live tty. */
|
|
120
|
+
ask?: (question: string) => Promise<string>
|
|
121
|
+
/** Override tty detection (tests). Default: stdin AND stdout are ttys. */
|
|
122
|
+
isTty?: boolean
|
|
123
|
+
warn?: (message: string) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TelegramStepResult {
|
|
127
|
+
state:
|
|
128
|
+
| 'created' // human peer created; user_id delivered via the self-config hook
|
|
129
|
+
| 'already' // a natural peer already exists — idempotent skip (never a second human)
|
|
130
|
+
| 'skipped-flag' // --no-telegram
|
|
131
|
+
| 'skipped-unavailable' // package install failed — soft skip
|
|
132
|
+
| 'refused-non-tty' // no tty and no flags — the STEP refuses, onboard continues
|
|
133
|
+
| 'invalid-input' // explicit flags carried an invalid name / user id — hard
|
|
134
|
+
| 'create-failed' // createPeer threw — hard
|
|
135
|
+
| 'dry-run'
|
|
136
|
+
personality?: string
|
|
137
|
+
detail?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function ttyAsk(question: string): Promise<string> {
|
|
141
|
+
const { createInterface } = await import('node:readline/promises')
|
|
142
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
143
|
+
try {
|
|
144
|
+
return (await rl.question(question)).trim()
|
|
145
|
+
} finally {
|
|
146
|
+
rl.close()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The registry's existing natural peer, if any (the idempotency key of the step). */
|
|
151
|
+
export function findNaturalPeer(env: NodeJS.ProcessEnv): string | null {
|
|
152
|
+
try {
|
|
153
|
+
const naturals = readPeersIndex({ env }).peers.filter(
|
|
154
|
+
p => normalizeIntelligenceValue(p.intelligence) === 'natural',
|
|
155
|
+
)
|
|
156
|
+
return naturals[0]?.personality ?? null
|
|
157
|
+
} catch {
|
|
158
|
+
return null // no registry yet → no natural peer
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function onboardTelegramStep(opts: TelegramStepOptions = {}): Promise<TelegramStepResult> {
|
|
163
|
+
const env = opts.env ?? process.env
|
|
164
|
+
if (opts.skip) return { state: 'skipped-flag' }
|
|
165
|
+
|
|
166
|
+
// Idempotency FIRST (before any install/questions): an existing natural peer
|
|
167
|
+
// means the host already has its human — the whole step is a no-op.
|
|
168
|
+
const existing = findNaturalPeer(env)
|
|
169
|
+
if (existing) {
|
|
170
|
+
return { state: 'already', personality: existing, detail: `human-пир "${existing}" уже есть — шаг пропущен` }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (opts.dryRun) {
|
|
174
|
+
return {
|
|
175
|
+
state: 'dry-run',
|
|
176
|
+
detail: `would npx-install ${RUNTIME_PACKAGES.telegram} + create the human peer (asks: name, telegram user_id)`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const install = installRuntimePackage({ runtime: 'telegram', env, runNpx: opts.runNpx })
|
|
181
|
+
if (install.state === 'failed' || install.state === 'no-package') {
|
|
182
|
+
return {
|
|
183
|
+
state: 'skipped-unavailable',
|
|
184
|
+
detail: `${install.package ?? 'telegram package'} install failed (${install.detail?.split('\n')[0] ?? 'npx non-zero'}) — install later: iapeer install-runtime telegram`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Resolve the two human-owed facts: flags → prompt (tty) → refuse (non-tty).
|
|
189
|
+
let human = opts.human?.trim()
|
|
190
|
+
let userId = opts.userId?.trim()
|
|
191
|
+
if (!human || !userId) {
|
|
192
|
+
const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
|
|
193
|
+
if (!tty) {
|
|
194
|
+
return {
|
|
195
|
+
state: 'refused-non-tty',
|
|
196
|
+
detail:
|
|
197
|
+
'no tty for the telegram questions — re-run with --telegram-human <name> --telegram-user-id <id>, ' +
|
|
198
|
+
'or skip with --no-telegram (add later: iapeer create <name> --runtime telegram)',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const ask = opts.ask ?? ttyAsk
|
|
202
|
+
if (!human) human = (await ask('telegram step — your human-peer name (short, latin): ')).trim()
|
|
203
|
+
if (human && !userId) {
|
|
204
|
+
userId = (await ask('your telegram user_id (message @userinfobot — it replies with the id): ')).trim()
|
|
205
|
+
}
|
|
206
|
+
if (!human || !userId) {
|
|
207
|
+
// An empty answer on the live tty is a "not now" — soft refusal of the step.
|
|
208
|
+
return {
|
|
209
|
+
state: 'refused-non-tty',
|
|
210
|
+
detail: 'no answer — telegram step skipped (add later: iapeer create <name> --runtime telegram)',
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const personality = normalizeNameCandidate(human)
|
|
216
|
+
if (!isValidName(personality)) {
|
|
217
|
+
return { state: 'invalid-input', detail: `invalid human-peer name "${human}" — must normalize to /^[a-z][a-z0-9-]{0,31}$/` }
|
|
218
|
+
}
|
|
219
|
+
if (!/^\d+$/.test(userId)) {
|
|
220
|
+
return { state: 'invalid-input', detail: `invalid telegram user_id "${userId}" — expected digits (ask @userinfobot)` }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// TELEGRAM_USER_ID rides the env into createPeer → initPeer → the package's
|
|
225
|
+
// self-config hook, which writes interfaces.telegram.user_id itself (owner's
|
|
226
|
+
// simplification, contract v0.10.0+ — no prepare / interface-human from the core).
|
|
227
|
+
const r = await createPeer({
|
|
228
|
+
personality,
|
|
229
|
+
runtime: 'telegram',
|
|
230
|
+
intelligence: 'natural',
|
|
231
|
+
env: { ...env, TELEGRAM_USER_ID: userId },
|
|
232
|
+
warn: opts.warn,
|
|
233
|
+
})
|
|
234
|
+
const sc = r.selfConfig?.state ?? 'absent'
|
|
235
|
+
if (sc === 'failed') {
|
|
236
|
+
return {
|
|
237
|
+
state: 'create-failed',
|
|
238
|
+
personality,
|
|
239
|
+
detail: `peer created but the telegram self-config hook failed: ${r.selfConfig?.detail ?? ''}`,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { state: 'created', personality, detail: `self-config ${sc}; bootstrap ${r.bootstrapped?.state ?? 'n/a'}` }
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return { state: 'create-failed', personality, detail: e instanceof Error ? e.message : String(e) }
|
|
245
|
+
}
|
|
246
|
+
}
|