@agfpd/iapeer 0.4.14 → 0.4.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/CHANGELOG.md +44 -2
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/core/constants.ts +9 -7
- package/src/launch/adapters/voicetalk.ts +95 -0
- package/src/launch/index.ts +2 -0
- package/src/launch/launchdRun.ts +1 -0
- package/src/launch/types.ts +2 -0
- package/src/lifecycle/index.ts +72 -36
- package/src/runtime/deploy.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,14 +10,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.16] - 2026-06-21
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Idle-reap now uses the pane-log (TUI render-stream) mtime as its activity proxy, not the
|
|
18
|
+
transcript file mtime. A live claude session re-touches its session `.jsonl` periodically
|
|
19
|
+
*without a real turn*, so the transcript proxy never crossed `idleSecs` and **claude peers
|
|
20
|
+
never idle-reaped** — the fleet piled up warm while codex peers (whose session file isn't
|
|
21
|
+
re-touched) reaped normally. The pane-log goes truly quiet at the prompt, so idle peers now
|
|
22
|
+
reap as intended. Pane-log primary; transcript fallback only when the pane-log is missing
|
|
23
|
+
(legacy supervisor); the launch ready-gate still uses the transcript.
|
|
24
|
+
|
|
25
|
+
## [0.4.15] - 2026-06-21
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Ephemeral idle-reap no longer kills a **working** session mid-turn. An armed ephemeral
|
|
30
|
+
worker (one that has sent its reply) was reaped after 20s of *transcript* silence, but the
|
|
31
|
+
transcript goes quiet during a long model generation — so a still-working worker was killed
|
|
32
|
+
mid-turn. The reap now folds in the pane-log (TUI render-stream) mtime, so "quiet" means the
|
|
33
|
+
turn truly ended: a working session is never reaped, while an idle one still reaps in ~20s
|
|
34
|
+
(keeping the ephemeral serial conveyor draining promptly).
|
|
35
|
+
|
|
36
|
+
## [0.4.14] - 2026-06-21
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Completed the bot_username cutover.** The telegram sender-guard (`hasTelegramPresence`)
|
|
41
|
+
now keys on `interfaces.telegram.bot_username` (the cutover's binding key) instead of the
|
|
42
|
+
legacy `bot`; the host registry was regenerated from the per-cwd source of truth, dropping
|
|
43
|
+
the stale `interfaces.telegram.bot` field across the fleet. telegram-runtime already
|
|
44
|
+
resolved on `bot_username`, so the foundation guard was the last consumer of `bot`.
|
|
45
|
+
- `recordDrift` (verify / reconcile) now compares the `interfaces` passport with an
|
|
46
|
+
order-insensitive canonical compare, so a source↔derived divergence is detected and healed
|
|
47
|
+
by `verify --fix` — the class that had let the derived registry silently keep a field
|
|
48
|
+
removed from the source of truth.
|
|
49
|
+
|
|
13
50
|
### Changed
|
|
14
51
|
|
|
15
52
|
- Fact-actualized the repo docs against the current code: pty-only (tmux references removed),
|
|
16
53
|
no canary/forensics in postmortem diagnostics, and the legacy `runtime`-mirror drop.
|
|
54
|
+
- Unified the README to the fleet documentation style.
|
|
17
55
|
|
|
18
56
|
### Added
|
|
19
57
|
|
|
20
|
-
- This changelog.
|
|
58
|
+
- This changelog (Keep a Changelog), shipped in the npm package.
|
|
59
|
+
- HD hero GIF in the README (1440×726 — sharper terminal text).
|
|
21
60
|
|
|
22
61
|
## [0.4.13] - 2026-06-20
|
|
23
62
|
|
|
@@ -206,7 +245,10 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
|
|
|
206
245
|
- **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
|
|
207
246
|
(timer = cron, watcher = event) are all first-class and addressed the same way.
|
|
208
247
|
|
|
209
|
-
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.
|
|
248
|
+
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.16...HEAD
|
|
249
|
+
[0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
|
|
250
|
+
[0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
|
|
251
|
+
[0.4.14]: https://github.com/agfpd/iapeer/compare/v0.4.13...v0.4.14
|
|
210
252
|
[0.4.13]: https://github.com/agfpd/iapeer/compare/v0.4.12...v0.4.13
|
|
211
253
|
[0.4.12]: https://github.com/agfpd/iapeer/compare/v0.4.11...v0.4.12
|
|
212
254
|
[0.4.11]: https://github.com/agfpd/iapeer/compare/v0.4.10...v0.4.11
|
package/README.md
CHANGED
|
@@ -20,18 +20,18 @@ Agents on Claude and on Codex are different AI runtimes from different vendors,
|
|
|
20
20
|
## How it works
|
|
21
21
|
|
|
22
22
|
```text
|
|
23
|
-
send_to_peer(name, text)
|
|
23
|
+
send_to_peer(name, text) ← from any peer
|
|
24
24
|
│
|
|
25
25
|
▼
|
|
26
|
-
router daemon
|
|
26
|
+
router daemon — always on; finds the recipient, checks liveness, delivers
|
|
27
27
|
│
|
|
28
28
|
▼
|
|
29
|
-
recipient peer
|
|
29
|
+
recipient peer — asleep? the daemon wakes it and delivers on wake
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## What makes it different
|
|
33
33
|
|
|
34
|
-
- **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
|
|
34
|
+
- **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.
|
|
35
35
|
- **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.
|
|
36
36
|
- **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.
|
|
37
37
|
- **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`.
|
package/package.json
CHANGED
package/src/core/constants.ts
CHANGED
|
@@ -56,7 +56,7 @@ export function normalizeIntelligenceValue(value: unknown): Intelligence | undef
|
|
|
56
56
|
return LEGACY_INTELLIGENCE[value]
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const NATURAL_RUNTIMES = new Set(['telegram', 'discord', 'matrix', 'email', 'web'])
|
|
59
|
+
const NATURAL_RUNTIMES = new Set(['telegram', 'discord', 'matrix', 'email', 'web', 'voicetalk'])
|
|
60
60
|
const ABSENT_RUNTIMES = new Set(['notifier', 'webhook', 'api', 'cron'])
|
|
61
61
|
|
|
62
62
|
export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
|
|
@@ -67,12 +67,12 @@ export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
|
|
|
67
67
|
|
|
68
68
|
// Infra runtimes are ALWAYS-ON (held live by launchd KeepAlive), as opposed to the
|
|
69
69
|
// warm-on-demand agentic runtimes (claude/codex, woken by the daemon). Liveness is
|
|
70
|
-
// a property of the RUNTIME, not the personality (zone Идентичность).
|
|
71
|
-
//
|
|
72
|
-
// notifier receives send_to_peer(timer|watcher, …)
|
|
73
|
-
//
|
|
74
|
-
// set gates always-on launchd plist generation (src/launch/launchd.ts).
|
|
75
|
-
const INFRA_RUNTIMES = new Set(['notifier', 'telegram'])
|
|
70
|
+
// a property of the RUNTIME, not the personality (zone Идентичность). Infra runtimes
|
|
71
|
+
// are routers hosted by the pty supervisor: telegram/voicetalk carry a human channel
|
|
72
|
+
// (inbound messages / voice), notifier receives send_to_peer(timer|watcher, …)
|
|
73
|
+
// registration/live-reload — outbound to each is deliverHosted → the child's stdin.
|
|
74
|
+
// This set gates always-on launchd plist generation (src/launch/launchd.ts).
|
|
75
|
+
const INFRA_RUNTIMES = new Set(['notifier', 'telegram', 'voicetalk'])
|
|
76
76
|
|
|
77
77
|
export function isInfraRuntime(runtime: string): boolean {
|
|
78
78
|
return INFRA_RUNTIMES.has(runtime)
|
|
@@ -89,6 +89,7 @@ export function isInfraRuntime(runtime: string): boolean {
|
|
|
89
89
|
export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
|
|
90
90
|
notifier: 'NOTIFIER_RUNTIME_BIN',
|
|
91
91
|
telegram: 'TELEGRAM_RUNTIME_BIN',
|
|
92
|
+
voicetalk: 'VOICETALK_RUNTIME_BIN',
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
/** The PATH-resolvable default launcher name per infra runtime (when no abs path
|
|
@@ -97,6 +98,7 @@ export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
|
|
|
97
98
|
export const INFRA_RUNTIME_DEFAULT_BIN: Readonly<Record<string, string>> = {
|
|
98
99
|
notifier: 'notifier-runtime',
|
|
99
100
|
telegram: 'telegram-runtime',
|
|
101
|
+
voicetalk: 'voicetalk-runtime',
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
// codex MCP token-free import (contract Установка §codex MCP). codex REFUSES to import
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// voicetalk RuntimeAdapter — the "HOW to launch / observe ONE voicetalk session"
|
|
2
|
+
// half of the launch contract (src/launch/types.ts). Like telegram, voicetalk is a
|
|
3
|
+
// long-running ROUTER (voicetalk-runtime run: mic + wake-word listen → IAP envelopes
|
|
4
|
+
// to the fleet; <iap>-marked stdin → TTS to the owner's speaker), NOT an LLM TUI. So
|
|
5
|
+
// kind:'router' tells launch.launch to SKIP every TUI phase — no pane boot dialog, no
|
|
6
|
+
// ready-marker, no first-user-turn delivery, no activity-proxy ready-gate, no
|
|
7
|
+
// permission modal, no transcript to resume. The pane predicates below are therefore
|
|
8
|
+
// trivial constants, present only to satisfy the frozen interface.
|
|
9
|
+
//
|
|
10
|
+
// voicetalk is a presence-runtime for a HUMAN (the owner's voice channel), modelled on
|
|
11
|
+
// telegram per the foundation presence-runtime contract: kind:'router',
|
|
12
|
+
// usesDoctrine:false, requiresIntelligence:'natural', argv = `<bin> run <extra>`. The
|
|
13
|
+
// STT/TTS engines and providers live in a shared voice service (voice-connect), NOT in
|
|
14
|
+
// this adapter and NOT in the peer passport — interfaces.voicetalk carries only the
|
|
15
|
+
// binding ({wake_word, device}), opaque to the foundation.
|
|
16
|
+
//
|
|
17
|
+
// NO currency on this path: no marketplace check, no plugin install/update — that is
|
|
18
|
+
// install-time (the package self-deploys its runtime.json manifest), not session
|
|
19
|
+
// bring-up.
|
|
20
|
+
|
|
21
|
+
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// voicetalkAdapter
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const voicetalkAdapter: RuntimeAdapter = {
|
|
28
|
+
runtime: 'voicetalk',
|
|
29
|
+
kind: 'router',
|
|
30
|
+
usesDoctrine: false,
|
|
31
|
+
|
|
32
|
+
/** No submit surface — a router takes no bracketed-paste-then-Enter turn; delivery
|
|
33
|
+
* is deliverHosted → child stdin (<iap> reader), not submitIntoTui. Empty glyphs. */
|
|
34
|
+
deliveryMarkers: { promptGlyphs: [] },
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* voicetalk is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
|
|
38
|
+
* Same nature gate as telegram: a peer with intelligence artificial/absent must never
|
|
39
|
+
* be brought up on voicetalk (it would route the owner's voice channel to an
|
|
40
|
+
* agent/script). The launch primitive enforces this against LaunchSpec.intelligence;
|
|
41
|
+
* source of intelligence → docs/Идентичность.
|
|
42
|
+
*/
|
|
43
|
+
requiresIntelligence: 'natural',
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* argv = voicetalkBin + 'run' + extraArgs.
|
|
47
|
+
* - cfg.voicetalkBin ?? 'voicetalk-runtime' the launch binary (the always-on
|
|
48
|
+
* plist pins an absolute path via VOICETALK_RUNTIME_BIN — launchd-minimal PATH;
|
|
49
|
+
* here we defer to cfg.voicetalkBin when set, else emit the PATH-resolvable
|
|
50
|
+
* literal for launch.launch to resolve).
|
|
51
|
+
* - 'run' the router subcommand: reads the peer-profile binding (interfaces.voicetalk),
|
|
52
|
+
* brings up mic + wake-word listening, and pumps IAP envelopes both directions
|
|
53
|
+
* through the session's stdio (outbound <iap> stdin → TTS; inbound mic → fleet via
|
|
54
|
+
* the daemon send_to_peer).
|
|
55
|
+
* - ...extraArgs LaunchSpec.extraArgs passthrough.
|
|
56
|
+
*
|
|
57
|
+
* NO --system-prompt-file: usesDoctrine:false — a router is not an LLM, no doctrine to
|
|
58
|
+
* merge. NO --resume: a router has no transcript. NO currency on this path.
|
|
59
|
+
*/
|
|
60
|
+
buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
|
|
61
|
+
return [cfg.voicetalkBin ?? 'voicetalk-runtime', 'run', ...(spec.extraArgs ?? [])]
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** No startup dialogs — a router has no pane TUI to answer. launch.launch skips the
|
|
65
|
+
* boot phase for kind:'router', so this is never consulted; null = "no dialog". */
|
|
66
|
+
bootDialogKeys(_pane: string): string[] | null {
|
|
67
|
+
return null
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/** Always ready: a router has no input surface waiting for a first message — it is
|
|
71
|
+
* "up" the instant `voicetalk-runtime run` is launched. launch.launch skips the
|
|
72
|
+
* ready-gate for kind:'router'; true = "nothing to wait for". */
|
|
73
|
+
isInputReady(_pane: string): boolean {
|
|
74
|
+
return true
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/** No activity proxy: a router writes no transcript/session jsonl to mtime as a
|
|
78
|
+
* ready-gate or idle signal. launch.launch skips the activity-proxy gate for a
|
|
79
|
+
* router; null per the contract ("null for a router (no proxy)"). */
|
|
80
|
+
newestActivityMtime(_cwd: string): number | null {
|
|
81
|
+
return null
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Nothing to resume: a router does not replay a transcript, so resume cannot
|
|
85
|
+
* fail-loud the way claude/codex do. Always {ok:true}. */
|
|
86
|
+
resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
|
|
87
|
+
return { ok: true }
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** No in-session control: a router has no TUI turn to interrupt/compact. null for
|
|
91
|
+
* every command → the daemon/CLI surfaces an explicit "unsupported" refusal. */
|
|
92
|
+
executeControl(_command: ControlCommand): ControlPlan | null {
|
|
93
|
+
return null
|
|
94
|
+
},
|
|
95
|
+
}
|
package/src/launch/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { claudeAdapter } from './adapters/claude.ts'
|
|
|
23
23
|
import { codexAdapter } from './adapters/codex.ts'
|
|
24
24
|
import { telegramAdapter } from './adapters/telegram.ts'
|
|
25
25
|
import { notifierAdapter } from './adapters/notifier.ts'
|
|
26
|
+
import { voicetalkAdapter } from './adapters/voicetalk.ts'
|
|
26
27
|
import type {
|
|
27
28
|
LaunchConfig,
|
|
28
29
|
LaunchFn,
|
|
@@ -88,6 +89,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
|
|
|
88
89
|
if (runtime === 'codex') return codexAdapter
|
|
89
90
|
if (runtime === 'telegram') return telegramAdapter
|
|
90
91
|
if (runtime === 'notifier') return notifierAdapter
|
|
92
|
+
if (runtime === 'voicetalk') return voicetalkAdapter
|
|
91
93
|
throw new Error(`no launch adapter for runtime "${runtime}"`)
|
|
92
94
|
}
|
|
93
95
|
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -96,6 +96,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
96
96
|
// (installAlwaysOnPlist) uses — so the pin and the read can never drift.
|
|
97
97
|
telegramBin: env[INFRA_RUNTIME_BIN_ENV.telegram],
|
|
98
98
|
notifierBin: env[INFRA_RUNTIME_BIN_ENV.notifier],
|
|
99
|
+
voicetalkBin: env[INFRA_RUNTIME_BIN_ENV.voicetalk],
|
|
99
100
|
sockDir,
|
|
100
101
|
bootDeadlineSecs: 30,
|
|
101
102
|
readyGateSecs: 30,
|
package/src/launch/types.ts
CHANGED
|
@@ -134,6 +134,8 @@ export interface LaunchAdapterConfig {
|
|
|
134
134
|
telegramBin?: string
|
|
135
135
|
/** notifier-runtime launch binary (router runtime, infra/always-on). */
|
|
136
136
|
notifierBin?: string
|
|
137
|
+
/** voicetalk-runtime launch binary (router runtime, infra/always-on — the owner's voice channel). */
|
|
138
|
+
voicetalkBin?: string
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
export interface LaunchSpec {
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
|
|
13
13
|
// sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
16
16
|
import { homedir } from 'os'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
@@ -74,20 +74,21 @@ export interface LifecycleConfig {
|
|
|
74
74
|
crashLoopWindowSecs: number
|
|
75
75
|
/** wake_policy:ephemeral M2 — the quiet window (seconds of activity-proxy silence)
|
|
76
76
|
* after which an ARMED ephemeral session (it has sent its outbound reply) is
|
|
77
|
-
* reaped.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
77
|
+
* reaped. Kept SHORT so the M3 serial conveyor drains the peer's next queued task
|
|
78
|
+
* promptly once its session is truly idle. "Quiet" here is BOTH the transcript proxy
|
|
79
|
+
* AND the pane-log (TUI render-stream) silent — the pane-log advances ~1s while the
|
|
80
|
+
* TUI renders a turn (generation OR a tool), so a still-working session is NEVER
|
|
81
|
+
* reaped mid-turn even though the transcript is momentarily quiet (the live incident
|
|
82
|
+
* this fixes: an armed worker generating its next step >20s was reaped at age=29s). */
|
|
81
83
|
ephemeralQuietSecs: number
|
|
82
84
|
/** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
|
|
83
85
|
* that never armed (finished silently / lost its arm to a daemon-restart window)
|
|
84
|
-
* is reaped after this much
|
|
85
|
-
* ended silently without its final outbound stalled its M3
|
|
86
|
-
* generic idleSecs (1 h) — with serial drain that blocks the whole
|
|
87
|
-
* silent worker. Bound chosen ≫
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* is lost — ephemeral workers must emit activity (or their reply) within it. */
|
|
86
|
+
* is reaped after this much TRUE-idle silence (transcript AND pane-log both quiet).
|
|
87
|
+
* Live case: a worker that ended silently without its final outbound stalled its M3
|
|
88
|
+
* FIFO for the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
|
|
89
|
+
* conveyor per silent worker. Bound chosen ≫ a brief no-output gap and ≪ idleSecs.
|
|
90
|
+
* A still-WORKING silent-tool worker keeps the pane-log fresh via the TUI render, so it
|
|
91
|
+
* is no longer reaped mid-task — only a genuinely idle/wedged session crosses this. */
|
|
91
92
|
ephemeralUnarmedIdleSecs: number
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -343,6 +344,21 @@ export function clearEphemeralArmed(cfg: LifecycleConfig, identity: string): voi
|
|
|
343
344
|
}
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
/** mtime (ms) of a hosted session's pane-log (`<logDir>/<identity>.log`) — the RAW TUI
|
|
348
|
+
* render byte-stream the supervisor appends for the session's whole life. It advances
|
|
349
|
+
* ~1s while the TUI is rendering a turn (the "esc to interrupt" elapsed counter ticks
|
|
350
|
+
* during BOTH model generation AND a tool run) and goes quiet at the prompt. So it is
|
|
351
|
+
* the truest active-turn liveness signal — the SAME mtime telegram-runtime reads for its
|
|
352
|
+
* typing/activity indicator — and unlike the transcript proxy it does NOT go quiet during
|
|
353
|
+
* a long model generation (no JSONL write until the message completes). null if absent. */
|
|
354
|
+
function paneLogMtimeMs(cfg: LifecycleConfig, identity: string): number | null {
|
|
355
|
+
try {
|
|
356
|
+
return statSync(join(cfg.logDir, `${identity}.log`)).mtimeMs
|
|
357
|
+
} catch {
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
346
362
|
function deathsPath(cfg: LifecycleConfig, identity: string): string {
|
|
347
363
|
return join(cfg.stateDir, `${identity}.deaths`)
|
|
348
364
|
}
|
|
@@ -1147,6 +1163,9 @@ export interface SuperviseDeps {
|
|
|
1147
1163
|
/** Liveness seam (default: the real `sessionAlive` = pty-host OR tmux-fallback). Tests inject a
|
|
1148
1164
|
* fixed verdict so the idle path is exercised hermetically — no live tmux/pty session needed. */
|
|
1149
1165
|
sessionAlive?: (sock: string, identity: string) => boolean
|
|
1166
|
+
/** Pane-log mtime seam (default: the real pane-log file mtime). Tests inject a controlled value to
|
|
1167
|
+
* exercise the ephemeral active-turn vs idle distinction without a real pane-log on disk. */
|
|
1168
|
+
paneLogMtime?: (identity: string) => number | null
|
|
1150
1169
|
}
|
|
1151
1170
|
|
|
1152
1171
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
@@ -1154,6 +1173,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1154
1173
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1155
1174
|
const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
|
|
1156
1175
|
const isAlive = deps.sessionAlive ?? sessionAlive
|
|
1176
|
+
const paneLogMt = deps.paneLogMtime ?? ((identity: string) => paneLogMtimeMs(cfg, identity))
|
|
1157
1177
|
const verbose = superviseLogVerbose(env)
|
|
1158
1178
|
// Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1159
1179
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
@@ -1226,32 +1246,45 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1226
1246
|
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1227
1247
|
continue
|
|
1228
1248
|
}
|
|
1229
|
-
// Idle accounting via the
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1249
|
+
// Idle accounting via the PANE-LOG (TUI render-stream) mtime — the RELIABLE idle
|
|
1250
|
+
// signal: it advances ~1s while the TUI renders ANY turn (model generation OR a tool
|
|
1251
|
+
// run — the "esc to interrupt" elapsed counter ticks) and goes quiet ONLY at the
|
|
1252
|
+
// prompt. NOT the transcript file mtime: a live claude session RE-TOUCHES its session
|
|
1253
|
+
// .jsonl ~every 30-45min WITHOUT a real turn (a system/bridge re-save), so the
|
|
1254
|
+
// transcript proxy is perpetually < idleSecs and a claude peer NEVER idle-reaps — the
|
|
1255
|
+
// fleet-reap incident: a warm claude pile, last real turn hours ago, never reaped;
|
|
1256
|
+
// codex's session file is NOT re-touched, so codex reaps (the claude-vs-codex split).
|
|
1257
|
+
// The transcript stays the launch ready-gate's signal (it needs "a real turn produced
|
|
1258
|
+
// output"); only this idle proxy moves to the pane-log. FLOORED at wokeAt: a session
|
|
1259
|
+
// cannot be idle longer than it has been awake (a wake that produced no turn →
|
|
1260
|
+
// mt=wokeAt → reaped idleSecs after the wake, never out from under an attaching op).
|
|
1261
|
+
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1262
|
+
const plMt = paneLogMt(s.identity)
|
|
1263
|
+
let mt: number
|
|
1264
|
+
if (plMt !== null) {
|
|
1265
|
+
mt = Math.max(plMt, s.wokeAt)
|
|
1266
|
+
} else {
|
|
1267
|
+
// Pane-log MISSING (a legacy pre-pane-log supervisor) → fall back to the transcript
|
|
1268
|
+
// proxy, NEVER to wokeAt-as-activity: a wokeAt-only age would read an ACTIVE legacy
|
|
1269
|
+
// session's idle from its hours-old wokeAt and reap it MID-WORK. The transcript is
|
|
1270
|
+
// false-fresh at idle (so an IDLE legacy peer lingers warm until its next respawn
|
|
1271
|
+
// earns a pane-log — bounded, self-heals), but an ACTIVE legacy peer keeps a
|
|
1272
|
+
// genuinely-fresh transcript and is correctly protected. Safe direction preserved.
|
|
1273
|
+
let tx = 0
|
|
1274
|
+
try { tx = activityMtime(s.runtime, s.cwd) ?? 0 } catch { /* no adapter → wokeAt */ }
|
|
1275
|
+
mt = Math.max(tx, s.wokeAt)
|
|
1243
1276
|
}
|
|
1244
1277
|
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1245
1278
|
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1246
1279
|
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
|
1247
|
-
// BEFORE the idle branch (quiet ≪ idle). Quiet = the
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
// idle bound below is its only reaper.
|
|
1252
|
-
// NO .idle-reaped (an ephemeral peer never resumes)
|
|
1253
|
-
// (the crash-loop ring counts faults, not policy reaps).
|
|
1254
|
-
if (
|
|
1280
|
+
// BEFORE the idle branch (quiet ≪ idle). Quiet = the pane-log silent for
|
|
1281
|
+
// ephemeralQuietSecs (the reliable render signal above) — a still-rendering turn
|
|
1282
|
+
// keeps it fresh, so the worker finishes its turn first; post-reply housekeeping
|
|
1283
|
+
// (operative-note writes) also advances it.
|
|
1284
|
+
// NOT armed (still mid-task) → the ordinary idle bound below is its only reaper.
|
|
1285
|
+
// Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
|
|
1286
|
+
// and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
|
|
1287
|
+
if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
|
|
1255
1288
|
killSession(sock, s.identity)
|
|
1256
1289
|
clearEphemeralArmed(cfg, s.identity)
|
|
1257
1290
|
removeSessionState(cfg, s.identity)
|
|
@@ -1274,8 +1307,11 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1274
1307
|
// silent-finish path is `iapeer self-done` (arm without waking anyone —
|
|
1275
1308
|
// the invariant «нет пустых пробуждений» stays intact); this branch only
|
|
1276
1309
|
// bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
|
|
1277
|
-
// (ephemeral never resumes), NO recordDeath (the ring counts faults).
|
|
1278
|
-
|
|
1310
|
+
// (ephemeral never resumes), NO recordDeath (the ring counts faults). The pane-log
|
|
1311
|
+
// fold-in above also covers this branch: a STILL-WORKING unarmed worker (a silent
|
|
1312
|
+
// tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
|
|
1313
|
+
// reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
|
|
1314
|
+
if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1279
1315
|
killSession(sock, s.identity)
|
|
1280
1316
|
clearEphemeralArmed(cfg, s.identity)
|
|
1281
1317
|
removeSessionState(cfg, s.identity)
|
package/src/runtime/deploy.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { readRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from
|
|
|
32
32
|
export const RUNTIME_PACKAGES: Readonly<Record<string, string>> = {
|
|
33
33
|
telegram: '@agfpd/telegram-runtime',
|
|
34
34
|
notifier: '@agfpd/notifier-runtime',
|
|
35
|
+
voicetalk: '@agfpd/voicetalk-runtime',
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/** Resolve the npm package for a runtime: explicit override wins, else the built-in
|