@agfpd/iapeer 0.2.80 → 0.2.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -14
- package/package.json +1 -1
- package/src/cli/index.ts +7 -2
- package/src/daemon/main.ts +3 -3
- package/src/identity/index.ts +13 -0
- package/src/init/index.ts +57 -4
- package/src/lifecycle/index.ts +61 -15
package/README.md
CHANGED
|
@@ -1,29 +1,77 @@
|
|
|
1
1
|
# iapeer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Host-level core for a team of AI agents on one machine.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@agfpd/iapeer)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](#quick-start)
|
|
6
8
|
|
|
7
|
-
iapeer
|
|
9
|
+
iapeer keeps a team of AI agents, humans, and services on one host **always reachable — every one of them a single message away.** Each participant is a **peer**: a Claude Code or Codex CLI agent, a person on Telegram, or a scheduler/watcher service. Any agent reaches any other with one call — `send_to_peer(<name>, <text>)` — and a peer that is asleep is still reachable: the daemon wakes it and delivers. Agents on Claude and on Codex — different AI runtimes from different vendors — talk as equal peers; humans take part over Telegram.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## How it works
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
```text
|
|
14
|
+
send_to_peer(name, text) ← from any peer
|
|
15
|
+
│
|
|
16
|
+
▼
|
|
17
|
+
router daemon — always on; finds the recipient, checks liveness, delivers
|
|
18
|
+
│
|
|
19
|
+
▼
|
|
20
|
+
recipient peer — asleep? the daemon wakes it and delivers on wake
|
|
21
|
+
```
|
|
12
22
|
|
|
13
|
-
|
|
23
|
+
## Quick start
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
Requirements: **macOS**, [bun](https://bun.sh) on `PATH`, `~/.local/bin` on `PATH`, and at least one agent runtime installed — Claude Code or Codex CLI (each AI peer is a live session of one).
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
```sh
|
|
28
|
+
# 1. install — the iapeer binary and the ~/.iapeer layout
|
|
29
|
+
npx @agfpd/iapeer
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
# 2. set up the host — starts the router daemon, then adds scheduler peers, Telegram, and shared memory
|
|
32
|
+
iapeer onboard
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
# 3. create your first agent
|
|
35
|
+
iapeer create assistant --runtime claude
|
|
22
36
|
|
|
23
|
-
|
|
37
|
+
# 4. talk to it live in your terminal — detach with Ctrl-]
|
|
38
|
+
iapeer attach assistant
|
|
39
|
+
```
|
|
24
40
|
|
|
25
|
-
|
|
41
|
+
**Already have a project folder?** In step 3, use `init` instead of `create`: `cd` into the project and register that folder itself as a peer.
|
|
26
42
|
|
|
27
|
-
|
|
43
|
+
```sh
|
|
44
|
+
cd ~/projects/myagent
|
|
45
|
+
iapeer init
|
|
46
|
+
```
|
|
28
47
|
|
|
29
|
-
|
|
48
|
+
The peer's name is the folder name, normalized (`myagent`). `create` sets up a fresh folder under `~/.iapeer/peers/<name>`; `init` turns the folder you are in into a peer. The setup is identical either way.
|
|
49
|
+
|
|
50
|
+
Two ways to work with an agent locally in your terminal:
|
|
51
|
+
|
|
52
|
+
- **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep; detach with `Ctrl-]` and the session keeps running.
|
|
53
|
+
- **Open a fresh session in the peer's folder** — go to the peer's directory and launch its runtime:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
cd ~/.iapeer/peers/assistant
|
|
57
|
+
iapeer claude
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This starts a full session with the peer's identity and doctrine and drops you straight into it. In effect, `iapeer claude` is `claude` for that peer — the same interactive CLI, but the session has a stable identity, its own memory, and an address other peers can reach.
|
|
61
|
+
|
|
62
|
+
Agents reach each other with `send_to_peer`; you can also message any peer over Telegram.
|
|
63
|
+
|
|
64
|
+
## What makes it different
|
|
65
|
+
|
|
66
|
+
- **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call.
|
|
67
|
+
- **Warm-on-demand lifecycle.** A live session answers fast; after about an hour idle the daemon closes it; the next message brings it back with its context resumed. The message flow drives the lifecycle — not a human's presence.
|
|
68
|
+
- **One identity across runtimes, with one memory.** A peer's `personality` is decoupled from its runtime — the same peer runs on Claude or Codex, switched with a command. By default (set up by `iapeer onboard`) it carries one shared memory (iapeer-memory) keyed to the personality, not the runtime: one personality, one memory, whatever runtime it runs on.
|
|
69
|
+
- **Heterogeneous peers, one protocol.** AI agents, humans (Telegram), and services (timer = cron, watcher = event) are all first-class, addressed the same way over an always-on MCP daemon that exposes exactly one tool, `send_to_peer`.
|
|
70
|
+
|
|
71
|
+
## Documentation
|
|
72
|
+
|
|
73
|
+
[`docs/`](https://github.com/agfpd/iapeer/tree/main/docs) — the product side (what it is and how to use it); [`docs/internals/`](https://github.com/agfpd/iapeer/tree/main/docs/internals) — the mechanics from the inside. This repository is the implementation.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
Apache-2.0. Platform: macOS / launchd. iapeer is the foundation core of a larger ecosystem; runtime and capability plugins build on top of it.
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -915,7 +915,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
915
915
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
916
916
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
917
917
|
update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
|
|
918
|
-
init [cwd] [--
|
|
918
|
+
init [cwd] [--runtime r] [--description d] onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)
|
|
919
919
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
920
920
|
list [--json] registered peers + per-runtime liveness
|
|
921
921
|
verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)
|
|
@@ -1182,11 +1182,16 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1182
1182
|
// cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
|
|
1183
1183
|
// identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
|
|
1184
1184
|
// when not explicit. Auto-bootstraps an infra plist unless --no-bootstrap.
|
|
1185
|
+
// A peer's name IS its folder name (normalize(basename(cwd))) — personality ↔ folder
|
|
1186
|
+
// is 1:1, so `--personality` is gone: name the folder, don't pass a separate name.
|
|
1187
|
+
if (flags.personality !== undefined) {
|
|
1188
|
+
errOut("init: --personality was removed — a peer's name is its folder name; rename the folder to set the name\n")
|
|
1189
|
+
return 2
|
|
1190
|
+
}
|
|
1185
1191
|
const { initPeer } = await import('../init/index.ts')
|
|
1186
1192
|
const r = await initPeer({
|
|
1187
1193
|
cwd: positionals[0] ?? process.cwd(),
|
|
1188
1194
|
runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
|
|
1189
|
-
personality: typeof flags.personality === 'string' ? flags.personality : undefined,
|
|
1190
1195
|
description: typeof flags.description === 'string' ? flags.description : undefined,
|
|
1191
1196
|
runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
|
|
1192
1197
|
bootstrap: flags['no-bootstrap'] === true ? false : undefined,
|
package/src/daemon/main.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
drainEphemeralQueue,
|
|
36
36
|
enqueueEphemeralTask,
|
|
37
37
|
isEphemeralPeer,
|
|
38
|
+
addTopic,
|
|
38
39
|
loadLifecycleConfig,
|
|
39
40
|
processEagerRelaunches,
|
|
40
41
|
readTopic,
|
|
@@ -42,7 +43,6 @@ import {
|
|
|
42
43
|
setEphemeralArmed,
|
|
43
44
|
superviseTick,
|
|
44
45
|
wakeOrSpawn,
|
|
45
|
-
writeTopic,
|
|
46
46
|
type LifecycleConfig,
|
|
47
47
|
} from '../lifecycle/index.ts'
|
|
48
48
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
@@ -135,8 +135,8 @@ export function makeNoteLiveTopic(
|
|
|
135
135
|
): (identity: string, topic: string) => void {
|
|
136
136
|
return (identity, topic) => {
|
|
137
137
|
try {
|
|
138
|
-
if (readTopic(cfg, identity) === topic) return
|
|
139
|
-
|
|
138
|
+
if (readTopic(cfg, identity) === topic) return // unchanged most-recent → no shift, skip the note
|
|
139
|
+
addTopic(cfg, identity, topic) // accumulate into the session's topic set (a new live thread)
|
|
140
140
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'topic-note', identity, topic }, { env })
|
|
141
141
|
} catch {
|
|
142
142
|
/* the marker is a discriminator hint — never fail a delivered message */
|
package/src/identity/index.ts
CHANGED
|
@@ -415,6 +415,19 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
|
|
|
415
415
|
let personality: string
|
|
416
416
|
if (options.personality !== undefined) {
|
|
417
417
|
personality = validatePersonality(options.personality, 'personality')
|
|
418
|
+
// INVARIANT (personality ↔ folder is 1:1): an explicitly-supplied personality MUST
|
|
419
|
+
// equal the normalized cwd basename. Otherwise the registry record (personality) and
|
|
420
|
+
// a manual `cd <cwd>; <runtime>` session (which self-derives identity from the cwd
|
|
421
|
+
// basename) would disagree. The only ways to reach a mismatch are a runtime manifest
|
|
422
|
+
// `peers[].path` or `create --path <dir>` whose basename ≠ personality — both are
|
|
423
|
+
// misconfigurations, surfaced loudly here rather than written as a latent drift.
|
|
424
|
+
const expected = normalizeNameCandidate(basename(cwd))
|
|
425
|
+
if (personality !== expected) {
|
|
426
|
+
throw new IapError(
|
|
427
|
+
`personality "${personality}" must equal the normalized cwd basename "${expected}" ` +
|
|
428
|
+
`(personality ↔ folder is 1:1) — name the folder to match, or drop the explicit name. cwd: ${cwd}`,
|
|
429
|
+
)
|
|
430
|
+
}
|
|
418
431
|
const collision = peerCollision(personality, cwd, peers)
|
|
419
432
|
if (collision) {
|
|
420
433
|
throw new IapError(
|
package/src/init/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
|
|
|
17
17
|
import { homedir } from 'os'
|
|
18
18
|
import { join } from 'path'
|
|
19
19
|
import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, IAPEER_DIR, isInfraRuntime, type Runtime } from '../core/constants.ts'
|
|
20
|
+
import { IapError } from '../core/errors.ts'
|
|
20
21
|
import {
|
|
21
22
|
ensureLocalIapScaffold,
|
|
22
23
|
pluginStateDir,
|
|
@@ -257,11 +258,63 @@ export function ensureDoctrineTemplate(cwd: string): { path: string; created: bo
|
|
|
257
258
|
* the old inconsistency where a `.codex`-only folder defaulted to claude as primary
|
|
258
259
|
* yet got a codex config — now the primary matches the markers, deterministically.
|
|
259
260
|
*/
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Is an AGENTIC runtime installed on this host? Checks (any hit ⇒ installed): env
|
|
263
|
+
* override (IAPEER_<RT>_BIN), the native installer location (~/.local/bin/claude), the
|
|
264
|
+
* bare name on PATH, and the runtime's global config dir (~/.claude / ~/.codex — a
|
|
265
|
+
* runtime that has ever run leaves one). SANDBOX: under IAPEER_TEST_SANDBOX it reports
|
|
266
|
+
* `true` (never probes the real host) — mirror of the install-side guards; a hermetic
|
|
267
|
+
* test that wants the real branches injects its own predicate into resolvePrimaryRuntime.
|
|
268
|
+
*/
|
|
269
|
+
export function isRuntimeInstalled(runtime: Runtime, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
270
|
+
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return true
|
|
271
|
+
const override = runtime === 'claude' ? env.IAPEER_CLAUDE_BIN : runtime === 'codex' ? env.IAPEER_CODEX_BIN : undefined
|
|
272
|
+
if (override?.trim()) return true
|
|
273
|
+
const home = env.HOME?.trim() || homedir()
|
|
274
|
+
if (runtime === 'claude' && existsSync(join(home, '.local', 'bin', 'claude'))) return true
|
|
275
|
+
if (Bun.which(runtime)) return true
|
|
276
|
+
if (runtime === 'claude' && existsSync(join(home, '.claude'))) return true
|
|
277
|
+
if (runtime === 'codex' && existsSync(join(home, '.codex'))) return true
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const AGENTIC_RUNTIMES: Runtime[] = ['claude', 'codex']
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Resolve a peer's primary AGENTIC runtime — install-aware:
|
|
285
|
+
* 1. explicit (`--runtime`) → use it, but VALIDATE it is installed (clear error instead
|
|
286
|
+
* of a silent launch failure later on an uninstalled runtime).
|
|
287
|
+
* 2. else a cwd runtime MARKER (.claude / .codex) → that runtime. The folder's already-
|
|
288
|
+
* established config takes PRECEDENCE over install-presence (a folder configured for
|
|
289
|
+
* X stays X even if Y is also installed; markers are not re-validated).
|
|
290
|
+
* 3. else from what is INSTALLED: exactly one → it; none → clear error (don't silently
|
|
291
|
+
* default to claude on a host without it); both → claude (deterministic default —
|
|
292
|
+
* resolution runs in non-interactive paths, so no prompt; `--runtime` picks codex).
|
|
293
|
+
* `isInstalled` is injectable for hermetic tests.
|
|
294
|
+
*/
|
|
295
|
+
export function resolvePrimaryRuntime(
|
|
296
|
+
cwd: string,
|
|
297
|
+
explicit?: Runtime,
|
|
298
|
+
isInstalled: (rt: Runtime) => boolean = rt => isRuntimeInstalled(rt),
|
|
299
|
+
): Runtime {
|
|
300
|
+
if (explicit) {
|
|
301
|
+
if (!isInstalled(explicit)) {
|
|
302
|
+
throw new IapError(
|
|
303
|
+
`runtime "${explicit}" is not installed on this host — install it (Claude Code / Codex CLI) or choose an installed runtime`,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
return explicit
|
|
307
|
+
}
|
|
262
308
|
if (existsSync(join(cwd, '.claude'))) return 'claude'
|
|
263
309
|
if (existsSync(join(cwd, '.codex'))) return 'codex'
|
|
264
|
-
|
|
310
|
+
const installed = AGENTIC_RUNTIMES.filter(isInstalled)
|
|
311
|
+
if (installed.length === 1) return installed[0]!
|
|
312
|
+
if (installed.length === 0) {
|
|
313
|
+
throw new IapError(
|
|
314
|
+
`no agentic runtime installed (Claude Code or Codex CLI) — install one, then create/init the peer`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
return 'claude' // both installed → deterministic default; pass --runtime codex to pick codex
|
|
265
318
|
}
|
|
266
319
|
|
|
267
320
|
export interface InitPeerOptions {
|
|
@@ -319,7 +372,7 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
319
372
|
// Runtime-aware + CONSISTENT: an explicit runtime wins; else the cwd's markers
|
|
320
373
|
// decide the primary (resolvePrimaryRuntime), so the primary always matches the
|
|
321
374
|
// config that gets written (no `.codex`-only-but-primary-claude mismatch).
|
|
322
|
-
const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime)
|
|
375
|
+
const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime, rt => isRuntimeInstalled(rt, env))
|
|
323
376
|
const provisioned = await provisionPeer({
|
|
324
377
|
cwd: opts.cwd,
|
|
325
378
|
runtime,
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -376,27 +376,63 @@ function topicPath(cfg: LifecycleConfig, identity: string): string {
|
|
|
376
376
|
return join(cfg.stateDir, `${identity}.topic`)
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
|
|
380
|
-
|
|
379
|
+
// Per-session TOPIC SET (executor resume discriminator). The `.topic` file holds the
|
|
380
|
+
// topics this session lineage has touched — newline-separated, most-recent LAST, deduped
|
|
381
|
+
// and capped. A peer can carry several threads in ONE session, so resume matches the
|
|
382
|
+
// incoming topic against ANY topic in the set: a later ping on an earlier thread continues
|
|
383
|
+
// that session's context instead of starting fresh and redoing work. The set is RESET on a
|
|
384
|
+
// FRESH wake (new lineage) and ACCUMULATES on the wake topic + every live delivery topic.
|
|
385
|
+
const MAX_SESSION_TOPICS = 16
|
|
386
|
+
|
|
387
|
+
/** All topics the current session lineage has touched (most-recent last). [] if none. */
|
|
388
|
+
export function readTopics(cfg: LifecycleConfig, identity: string): string[] {
|
|
381
389
|
try {
|
|
382
|
-
return readFileSync(topicPath(cfg, identity), 'utf8')
|
|
390
|
+
return readFileSync(topicPath(cfg, identity), 'utf8')
|
|
391
|
+
.split('\n')
|
|
392
|
+
.map(t => t.trim())
|
|
393
|
+
.filter(Boolean)
|
|
383
394
|
} catch {
|
|
384
|
-
return
|
|
395
|
+
return []
|
|
385
396
|
}
|
|
386
397
|
}
|
|
387
398
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
399
|
+
/** The session's MOST-RECENT topic (last in the set), or '' — single-value back-compat read. */
|
|
400
|
+
export function readTopic(cfg: LifecycleConfig, identity: string): string {
|
|
401
|
+
const t = readTopics(cfg, identity)
|
|
402
|
+
return t.length ? t[t.length - 1]! : ''
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Is `topic` (non-empty) one of the session lineage's topics? Drives resume-vs-fresh. */
|
|
406
|
+
export function hasTopic(cfg: LifecycleConfig, identity: string, topic: string): boolean {
|
|
407
|
+
const t = topic.trim()
|
|
408
|
+
return !!t && readTopics(cfg, identity).includes(t)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Add a topic to the session's set (most-recent-last, deduped, capped). Empty → no-op.
|
|
412
|
+
* Two callers: the wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic
|
|
413
|
+
* seam (makeNoteLiveTopic) — so the set follows the work, not just the boot. Best-effort. */
|
|
414
|
+
export function addTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
|
|
415
|
+
const t = topic.trim()
|
|
416
|
+
if (!t) return
|
|
392
417
|
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
393
418
|
try {
|
|
394
|
-
|
|
419
|
+
const set = readTopics(cfg, identity).filter(x => x !== t)
|
|
420
|
+
set.push(t)
|
|
421
|
+
writeFileSync(topicPath(cfg, identity), set.slice(-MAX_SESSION_TOPICS).join('\n') + '\n', { mode: 0o600 })
|
|
395
422
|
} catch {
|
|
396
423
|
/* best-effort — topic is a discriminator hint, never blocks a wake */
|
|
397
424
|
}
|
|
398
425
|
}
|
|
399
426
|
|
|
427
|
+
/** Drop the session's topic set — a FRESH wake starts a new lineage with no carried topics. */
|
|
428
|
+
export function resetTopics(cfg: LifecycleConfig, identity: string): void {
|
|
429
|
+
try {
|
|
430
|
+
rmSync(topicPath(cfg, identity), { force: true })
|
|
431
|
+
} catch {
|
|
432
|
+
/* best-effort */
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
400
436
|
/**
|
|
401
437
|
* Purge EVERY identity-keyed lifecycle artifact of `<identity>` from stateDir:
|
|
402
438
|
* the marker files (`.stopped` / `.idle-reaped` / `.deaths` / `.topic` /
|
|
@@ -555,10 +591,17 @@ export function resolveWakeMode(
|
|
|
555
591
|
? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-human' }
|
|
556
592
|
: { resume: false, cause: 'idle-reaped-human-no-resume' }
|
|
557
593
|
}
|
|
558
|
-
// executor:
|
|
559
|
-
//
|
|
594
|
+
// executor: RESUME is an INTENTIONAL "continue THIS work" signal — it happens ONLY when
|
|
595
|
+
// the incoming message carries a topic that matches the session's last topic. A DIFFERENT
|
|
596
|
+
// topic (new work) OR NO topic at all → FRESH. Rationale: a topicless ping after an idle
|
|
597
|
+
// gap is not a request to resume specific work; defaulting it to resume revived stale
|
|
598
|
+
// context (e.g. a 3-day-old session). The sender opts INTO continuity by passing the topic.
|
|
560
599
|
const topic = incomingTopic?.trim() ?? ''
|
|
561
|
-
if (topic
|
|
600
|
+
if (!topic) return { resume: false, cause: 'idle-reaped-no-topic' }
|
|
601
|
+
// RESUME if the incoming topic matches ANY thread this session lineage worked on — not
|
|
602
|
+
// just the last. Different threads accumulate in one session; a later ping on an earlier
|
|
603
|
+
// thread continues that context. A topic the session never touched → new work → FRESH.
|
|
604
|
+
if (!hasTopic(cfg, identity, topic)) return { resume: false, cause: 'idle-reaped-new-topic' }
|
|
562
605
|
const r = resolveResume(cwd)
|
|
563
606
|
return r.ok
|
|
564
607
|
? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-resume' }
|
|
@@ -1053,9 +1096,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1053
1096
|
// reply. ONLY here on the actual launch path, NEVER on the live-session fast path
|
|
1054
1097
|
// above (a live session may be legitimately armed mid-quiet-window).
|
|
1055
1098
|
clearEphemeralArmed(cfg, identity)
|
|
1056
|
-
// Establish the session's topic (executor discriminator) and reset the
|
|
1057
|
-
// ring
|
|
1058
|
-
|
|
1099
|
+
// Establish/extend the session's topic SET (executor resume discriminator) and reset the
|
|
1100
|
+
// crash-loop ring. A FRESH wake starts a new lineage → drop the old topic set; a RESUME
|
|
1101
|
+
// keeps accumulating. The wake's own topic joins the set (no-op if empty). resolveWakeMode
|
|
1102
|
+
// already read the OLD set above, so mutating here is safe. Best-effort.
|
|
1103
|
+
if (!mode.resume) resetTopics(cfg, identity)
|
|
1104
|
+
addTopic(cfg, identity, args.topic?.trim() ?? '')
|
|
1059
1105
|
trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
|
|
1060
1106
|
return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
|
|
1061
1107
|
})
|