@cryptolibertus/pi-peer 0.3.2 → 0.3.5
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 +57 -9
- package/extensions/pi-peer/index.ts +34 -4
- package/package.json +2 -2
- package/src/peers/command.mjs +10 -5
- package/src/peers/config.mjs +13 -0
- package/src/peers/goal-board.mjs +83 -3
- package/src/peers/idle-watcher.mjs +194 -0
- package/src/peers/inbound-bridge.mjs +47 -6
- package/src/peers/local-transport.mjs +52 -2
- package/src/peers/runtime.mjs +14 -2
- package/src/peers/status.mjs +2 -1
package/README.md
CHANGED
|
@@ -12,9 +12,11 @@ pi install ./packages/pi-peer
|
|
|
12
12
|
|
|
13
13
|
## What it adds
|
|
14
14
|
|
|
15
|
-
- `/peer help|setup|doctor|status|list|init|reconnect|resume|cancel|send|get|await|goal`
|
|
15
|
+
- `/peer help|setup|doctor|status|list|init|reconnect|resume|cancel|send|get|await|goal|scout`
|
|
16
16
|
- `peer_list`, `peer_send`, `peer_get`, `peer_await`, and `peer_progress` tools
|
|
17
17
|
- Local peer discovery and transport using project `.pi/peers.json`
|
|
18
|
+
- Repo-scoped discovery: only Pi sessions in the same git repo/project appear as local peers
|
|
19
|
+
- Idle watcher daemon: idle peers nudge stuck inbound activations and proactively inspect open goal-board work
|
|
18
20
|
- Protocol compatibility metadata (`protocolVersion`, min/max compatible versions), peer manifests, capabilities, and trust summaries in descriptors/status/list output
|
|
19
21
|
- `PI_PEER_ID` runtime override for running multiple local Pi sessions
|
|
20
22
|
|
|
@@ -28,11 +30,11 @@ pi install ./packages/pi-peer
|
|
|
28
30
|
/peer cancel <message-id> "superseded"
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
`/peer setup` is a guided alias for `/peer init`: it creates `.pi/peers.json` only when the file is missing, seeds protocol/capability/trust manifest metadata, and never overwrites an existing config. `/peer doctor` is read-only and checks enablement, local identity, advertised endpoint, protocol compatibility, discovered peers, warnings, and resumable disconnected tasks. `/peer reconnect` refreshes local discovery after starting or restarting another Pi session. `/peer resume` re-dispatches a disconnected message restored from `.pi/peer-messages.json`; `/peer cancel` records a local cancellation so stale work is no longer treated as active.
|
|
33
|
+
`/peer setup` is a guided alias for `/peer init`: it creates `.pi/peers.json` only when the file is missing, seeds protocol/capability/trust manifest metadata, and never overwrites an existing config. `/peer doctor` is read-only and checks enablement, local identity, advertised endpoint, protocol compatibility, discovered peers, warnings, and resumable disconnected tasks. `/peer reconnect` refreshes local discovery after starting or restarting another Pi session. Discovery is repo/project scoped: sessions under the same `.git` root see each other, while sessions in other repos are ignored. Outside git, the resolved cwd is used as the scope. `/peer resume` re-dispatches a disconnected message restored from `.pi/peer-messages.json`; `/peer cancel` records a local cancellation so stale work is no longer treated as active.
|
|
32
34
|
|
|
33
35
|
## Flat goal board
|
|
34
36
|
|
|
35
|
-
`/peer goal` provides a local blackboard for flat peer collaboration. Peers can create a shared goal, post findings/tasks/handoffs, claim read or write leases, object, resolve objections, and vote without a planner assigning every step.
|
|
37
|
+
`/peer goal` provides a local blackboard for flat peer collaboration. Peers can create a shared goal, post findings/tasks/proposals/handoffs, claim read or write leases, object, resolve objections, scout for proactive next steps, and vote without a planner assigning every step.
|
|
36
38
|
|
|
37
39
|
Useful commands (long form and short aliases):
|
|
38
40
|
|
|
@@ -42,6 +44,8 @@ Useful commands (long form and short aliases):
|
|
|
42
44
|
/peer send worker "Fix PR waiting path" --goal <goal-id> --claim extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs --no-await
|
|
43
45
|
/peer progress "tests are running" --phase verification
|
|
44
46
|
/peer goal finding <goal-id> "PR auto-close can close before merge" --path extensions/symphony/index.ts
|
|
47
|
+
/peer scout <goal-id> --limit 5
|
|
48
|
+
/peer goal propose <goal-id> "Add a read-only reviewer before closing" --path extensions/symphony/index.ts
|
|
45
49
|
/peer goal claim <goal-id> "Fix PR waiting path" --mode write --path extensions/symphony/index.ts,test/pr-watcher-runtime.test.mjs
|
|
46
50
|
/peer goal heartbeat <goal-id> <claim-event-id> "still working after reconnect" --stale-after-ms 900000
|
|
47
51
|
/peer goal release <goal-id> <claim-event-id> "worker lane complete"
|
|
@@ -50,21 +54,65 @@ Useful commands (long form and short aliases):
|
|
|
50
54
|
/peer get <goal-id>
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer current`, `/peer fanout`, `/peer take`/`/peer claim`, `/peer complete`/`/peer done`, `/peer objection`/`/peer block`, `/peer unblock`, `/peer note`, `/peer finding`, `/peer ping`/`/peer heartbeat`, `/peer drop`/`/peer release`, `/peer pass`, `/peer fail`, `/peer vote`, and `/peer close` map to the corresponding `/peer goal ...` actions.
|
|
57
|
+
Short aliases keep common board updates terse: `/peer goals`/`/peer ls`, `/peer current`, `/peer scout`, `/peer fanout`, `/peer proposal`/`/peer propose`, `/peer take`/`/peer claim`, `/peer complete`/`/peer done`, `/peer objection`/`/peer block`, `/peer unblock`, `/peer note`, `/peer finding`, `/peer ping`/`/peer heartbeat`, `/peer drop`/`/peer release`, `/peer pass`, `/peer fail`, `/peer vote`, and `/peer close` map to the corresponding `/peer goal ...` actions.
|
|
54
58
|
|
|
55
59
|
The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots are stored in `.pi/peer-messages.json` so restarted planners can still inspect disconnected historical tasks. Mutating goal-board operations take a short local lock before load/modify/save so concurrent peer appends do not drop events. `/peer send --goal <goal-id> --claim <path[,path]>` and the `peer_send` tool's `goalId`/`claimedPaths` parameters link long-running peer tasks to the board: Symphony records a task, claims overlapping write paths before dispatch, injects goal/heartbeat instructions into the peer prompt, keeps the claim alive with local heartbeats, and releases the claim after the peer returns a final response. `/peer goal fanout` turns a goal into role-specific peer lanes, while `peer_progress` reports checkpoints from an inbound long-running task. Active write claims conflict on overlapping paths; released, stale, or expired claims are kept visible but inactive. Claims become stale after 45 minutes without a heartbeat unless the claim or heartbeat sets `--stale-after-ms`.
|
|
56
60
|
|
|
57
|
-
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, and no active write claims. Stale write claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect and `--force` only when intentionally overriding the readiness gate. Goal-linked tasks validate final handoff headings (`Status`, `Files changed`, `Verification`, `Blockers/risks`, `Safe for review`); missing sections create a blocking objection while still releasing the write claim. For multi-part work, use the fan-out gate: list peers, create/reuse a goal, delegate research/review/worker lanes, and include `Fan-out used: yes/no` plus peer handles in the final answer.
|
|
61
|
+
Normal goal closure requires at least one current passing vote, no current failed votes, no unresolved blocking objections, and no active write claims. Open proposals are intentionally non-blocking: they let peers show initiative without freezing closure. Stale write claims no longer block closure or new overlapping claims; use `/peer goal heartbeat` to revive work after a reconnect and `--force` only when intentionally overriding the readiness gate. Goal-linked tasks validate final handoff headings (`Status`, `Files changed`, `Verification`, `Blockers/risks`, `Safe for review`); missing sections create a blocking objection while still releasing the write claim. For multi-part work, use the fan-out gate: list peers, create/reuse a goal, delegate research/review/worker lanes, and include `Fan-out used: yes/no` plus peer handles in the final answer.
|
|
62
|
+
|
|
63
|
+
## Idle watcher
|
|
64
|
+
|
|
65
|
+
When peer messaging is enabled, the extension starts a lightweight in-process idle watcher on `session_start`. It only acts when Pi reports the agent is idle and there are no queued local follow-up messages. The watcher does two things:
|
|
66
|
+
|
|
67
|
+
1. If an inbound peer task is active but appears not to have triggered a turn, it re-nudges the existing inbound prompt with `triggerTurn: true` using a cooldown.
|
|
68
|
+
2. If no peer task is active, it reads `.pi/peer-goals.json`, derives the same read-only scout suggestions as `/peer scout`, and injects a concise self-prompt so the idle peer can propose, review, claim, vote, or no-op safely.
|
|
69
|
+
|
|
70
|
+
Configuration can be placed in `.pi/peers.json` as `idleWatcher` or in `.pi/settings.json` under `peerMessaging.idleWatcher`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"idleWatcher": {
|
|
75
|
+
"enabled": true,
|
|
76
|
+
"intervalMs": 15000,
|
|
77
|
+
"cooldownMs": 300000,
|
|
78
|
+
"maxActivationsPerSession": 20
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Set `PI_PEER_IDLE_WATCHER=off` to disable it for a process. `PI_PEER_IDLE_WATCHER_INTERVAL_MS` and `PI_PEER_IDLE_WATCHER_COOLDOWN_MS` override timing for local testing.
|
|
58
84
|
|
|
59
85
|
## Package checks
|
|
60
86
|
|
|
61
87
|
```bash
|
|
62
|
-
npm
|
|
63
|
-
npm
|
|
64
|
-
npm
|
|
65
|
-
npm
|
|
88
|
+
npm test
|
|
89
|
+
npm run check:pack
|
|
90
|
+
npm run smoke:pack
|
|
91
|
+
npm run check
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Publish to npm
|
|
95
|
+
|
|
96
|
+
Use this release workflow after landing package changes on `main`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Keep local peer runtime state out of release commits.
|
|
100
|
+
echo ".pi/" >> .git/info/exclude
|
|
101
|
+
|
|
102
|
+
# Verify the package before changing the version.
|
|
103
|
+
git status --short
|
|
104
|
+
npm run check
|
|
105
|
+
|
|
106
|
+
# Bump, commit, and tag the next patch version.
|
|
107
|
+
npm version patch
|
|
108
|
+
|
|
109
|
+
# Push the release commit and tag, then publish.
|
|
110
|
+
git push origin main --tags
|
|
111
|
+
npm publish --access public
|
|
66
112
|
```
|
|
67
113
|
|
|
114
|
+
If `npm version patch` reports `Git working directory not clean`, inspect `git status --short`. Do not commit `.pi/`; add it to `.git/info/exclude` or remove local runtime files. If `package.json` was already bumped by the failed command, either commit/tag it manually or reset `package.json` and rerun `npm version patch`.
|
|
115
|
+
|
|
68
116
|
## Notes
|
|
69
117
|
|
|
70
118
|
This package is MIT licensed and published from <https://github.com/CryptoLibertus/pi-peer>. Please use GitHub issues for bugs and feature requests. Peer writes and project mutations still depend on the receiving Pi session's normal approval and safety rules.
|
|
@@ -6,7 +6,7 @@ import { installPeerRuntimeLifecycle } from "../../src/peers/extension-lifecycle
|
|
|
6
6
|
import { initPeerConfig } from "../../src/peers/config.mjs";
|
|
7
7
|
import { formatPeerCommandError, formatPeerHelp, formatPeerInitResult, parsePeerCommand } from "../../src/peers/command.mjs";
|
|
8
8
|
import { createPeerRuntime, getPeerRuntimeValue } from "../../src/peers/runtime.mjs";
|
|
9
|
-
import { appendPeerGoalEvent, beginPeerGoalTask, closePeerGoal, completePeerGoalTask, createPeerGoal, formatPeerGoal, formatPeerGoalList, loadPeerGoalBoard, recordPeerGoalTaskDispatch } from "../../src/peers/goal-board.mjs";
|
|
9
|
+
import { appendPeerGoalEvent, beginPeerGoalTask, closePeerGoal, completePeerGoalTask, createPeerGoal, formatPeerGoal, formatPeerGoalList, formatPeerGoalScout, loadPeerGoalBoard, recordPeerGoalTaskDispatch } from "../../src/peers/goal-board.mjs";
|
|
10
10
|
import { collectPeerRuntimeStatus, derivePeerDoctorReport, formatPeerDoctorText, formatPeerStatusLines, formatPeerStatusText } from "../../src/peers/status.mjs";
|
|
11
11
|
import {
|
|
12
12
|
peerAwaitToolResult,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
peerSendTimeoutToolResult,
|
|
18
18
|
} from "../../src/peers/tool-results.mjs";
|
|
19
19
|
import { PEER_TOOL_NAMES, PEER_TOOL_PROMPT_GUIDELINES } from "../../src/peers/guidance.mjs";
|
|
20
|
+
import { createPeerIdleWatcher } from "../../src/peers/idle-watcher.mjs";
|
|
20
21
|
|
|
21
22
|
const MESSAGE_TYPE = "pi-peer";
|
|
22
23
|
const runtimeByCwd = new Map<string, Promise<any>>();
|
|
@@ -35,6 +36,7 @@ export default function piPeerExtension(pi: ExtensionAPI) {
|
|
|
35
36
|
activeContext = ctx;
|
|
36
37
|
const runtime = await runtimeFor(pi, ctx.cwd);
|
|
37
38
|
attachPeerUi(runtime, () => activeContext, (current: any) => refreshPeerUi(current, runtime));
|
|
39
|
+
attachPeerIdleWatcher(pi, runtime, () => activeContext, (current: any) => refreshPeerUi(current, runtime));
|
|
38
40
|
await refreshPeerUi(ctx, runtime);
|
|
39
41
|
});
|
|
40
42
|
|
|
@@ -42,16 +44,19 @@ export default function piPeerExtension(pi: ExtensionAPI) {
|
|
|
42
44
|
activeContext = ctx;
|
|
43
45
|
const runtime = await runtimeFor(pi, ctx.cwd);
|
|
44
46
|
await refreshPeerUi(ctx, runtime);
|
|
47
|
+
schedulePeerIdleCheck(runtime, "agent_end");
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
pi.on("session_shutdown", async (_event, ctx = {}) => {
|
|
48
|
-
|
|
51
|
+
const runtime = await runtimeFor(pi, ctx.cwd);
|
|
52
|
+
runtime.__peerIdleWatcher?.stop?.();
|
|
53
|
+
await refreshPeerUi(ctx, runtime);
|
|
49
54
|
activeContext = undefined;
|
|
50
55
|
});
|
|
51
56
|
|
|
52
57
|
pi.registerCommand("peer", {
|
|
53
58
|
description: "Pi-to-Pi peers: setup, doctor, status, list, send, get, await, progress, goal",
|
|
54
|
-
getArgumentCompletions: (prefix: string) => ["help", "status", "list", "init", "setup", "doctor", "reconnect", "resume", "cancel", "send", "get", "await", "progress", "goal", "goals", "ls", "current", "fanout", "claim", "take", "done", "complete", "block", "objection", "unblock", "pass", "fail"]
|
|
59
|
+
getArgumentCompletions: (prefix: string) => ["help", "status", "list", "init", "setup", "doctor", "reconnect", "resume", "cancel", "send", "get", "await", "progress", "goal", "goals", "ls", "current", "scout", "fanout", "proposal", "propose", "claim", "take", "done", "complete", "block", "objection", "unblock", "pass", "fail"]
|
|
55
60
|
.filter((value) => value.startsWith(prefix))
|
|
56
61
|
.map((value) => ({ value, label: value })),
|
|
57
62
|
handler: async (rawArgs, ctx) => {
|
|
@@ -409,8 +414,9 @@ async function handlePeerGoalCommand(parsed: any, ctx: any, runtime: any) {
|
|
|
409
414
|
if (!goal) throw new Error(goalId ? `peer goal ${goalId} not found` : "no current peer goal");
|
|
410
415
|
return formatPeerGoal(goal);
|
|
411
416
|
}
|
|
417
|
+
if (parsed.goalAction === "scout") return formatPeerGoalScout(await loadPeerGoalBoard(root), { goalId: parsed.goalId, limit: parsed.limit, includeClosed: parsed.includeClosed });
|
|
412
418
|
if (parsed.goalAction === "fanout") return handlePeerGoalFanout(parsed, ctx, runtime, peerId);
|
|
413
|
-
if (["task", "finding", "handoff", "note"].includes(parsed.goalAction)) {
|
|
419
|
+
if (["task", "finding", "proposal", "propose", "handoff", "note"].includes(parsed.goalAction)) {
|
|
414
420
|
const result = await appendPeerGoalEvent(root, parsed.goalId, {
|
|
415
421
|
type: parsed.eventType,
|
|
416
422
|
peerId,
|
|
@@ -580,6 +586,7 @@ async function resetRuntimeFor(cwd?: string) {
|
|
|
580
586
|
const pending = runtimeByCwd.get(key);
|
|
581
587
|
runtimeByCwd.delete(key);
|
|
582
588
|
const runtime = await pending?.catch(() => undefined);
|
|
589
|
+
runtime?.__peerIdleWatcher?.stop?.();
|
|
583
590
|
await runtime?.dispose?.();
|
|
584
591
|
}
|
|
585
592
|
|
|
@@ -592,6 +599,29 @@ function attachPeerUi(runtime: any, activeContext: () => any, refresh: (ctx: any
|
|
|
592
599
|
});
|
|
593
600
|
}
|
|
594
601
|
|
|
602
|
+
function attachPeerIdleWatcher(pi: ExtensionAPI, runtime: any, activeContext: () => any, refresh: (ctx: any) => Promise<void>) {
|
|
603
|
+
if (!runtime?.enabled || !runtime?.comms) return false;
|
|
604
|
+
if (!runtime.__peerIdleWatcher) {
|
|
605
|
+
runtime.__peerIdleWatcher = createPeerIdleWatcher({
|
|
606
|
+
pi,
|
|
607
|
+
runtime,
|
|
608
|
+
activeContext,
|
|
609
|
+
refresh,
|
|
610
|
+
messageType: MESSAGE_TYPE,
|
|
611
|
+
env: process.env,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return runtime.__peerIdleWatcher.start?.();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function schedulePeerIdleCheck(runtime: any, reason: string) {
|
|
618
|
+
if (!runtime?.__peerIdleWatcher?.check) return;
|
|
619
|
+
const timer = setTimeout(() => {
|
|
620
|
+
void runtime.__peerIdleWatcher.check(reason).catch(() => {});
|
|
621
|
+
}, 0);
|
|
622
|
+
timer.unref?.();
|
|
623
|
+
}
|
|
624
|
+
|
|
595
625
|
function ensureEnabled(runtime: any) {
|
|
596
626
|
if (!runtime.enabled) throw new Error("Pi-to-Pi peer messaging is disabled for this project. Run /peer init or enable experimental.peerMessaging before using peer send/get/await.");
|
|
597
627
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptolibertus/pi-peer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Pi package for local Pi-to-Pi peer messaging, slash commands, tools, and runtime transport.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"LICENSE"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test
|
|
21
|
+
"test": "node --test test/peer-*.test.mjs",
|
|
22
22
|
"check": "npm test && npm run check:pack",
|
|
23
23
|
"check:pack": "npm pack --dry-run",
|
|
24
24
|
"smoke:pack": "npm run check:pack",
|
package/src/peers/command.mjs
CHANGED
|
@@ -7,6 +7,9 @@ const PEER_GOAL_ALIASES = Object.freeze({
|
|
|
7
7
|
ls: ["list"],
|
|
8
8
|
current: ["show"],
|
|
9
9
|
fanout: ["fanout"],
|
|
10
|
+
scout: ["scout"],
|
|
11
|
+
proposal: ["proposal"],
|
|
12
|
+
propose: ["propose"],
|
|
10
13
|
claim: ["claim"],
|
|
11
14
|
take: ["claim"],
|
|
12
15
|
heartbeat: ["heartbeat"],
|
|
@@ -136,11 +139,12 @@ export function formatPeerHelp() {
|
|
|
136
139
|
"- `/peer cancel <message-id> [reason]` — mark a queued/running/disconnected peer message cancelled",
|
|
137
140
|
"- `/peer send <peer> <prompt> [--no-await] [--intent ask] [--goal <goal-id>] [--claim <path[,path]>] [--timeout-ms <ms>] [--allow-self]` — send a prompt-first peer message",
|
|
138
141
|
"- `/peer progress <summary> [--status running] [--phase <name>]` — send a structured checkpoint from an inbound long-running peer task",
|
|
139
|
-
"- `/peer goals|ls`, `/peer current [goal-id]`, `/peer fanout`, `/peer take|claim`, `/peer complete|done`, `/peer objection|block`, `/peer unblock`, `/peer ping`, `/peer drop`, `/peer pass|fail` — short goal-board aliases",
|
|
142
|
+
"- `/peer goals|ls`, `/peer current [goal-id]`, `/peer scout [goal-id]`, `/peer fanout`, `/peer propose`, `/peer take|claim`, `/peer complete|done`, `/peer objection|block`, `/peer unblock`, `/peer ping`, `/peer drop`, `/peer pass|fail` — short goal-board aliases",
|
|
140
143
|
"- `/peer goal create <objective> [--constraint <a,b>]` — start a flat shared goal board",
|
|
141
|
-
"- `/peer goal list|show [goal-id]` — inspect peer goals, active claims, blockers, and votes",
|
|
144
|
+
"- `/peer goal list|show [goal-id]` — inspect peer goals, active claims, blockers, proposals, and votes",
|
|
142
145
|
"- `/peer goal fanout <goal-id> <objective> --peer <id[,id]> [--path <a,b>] [--send] [--no-await]` — plan or dispatch role-specific peer lanes",
|
|
143
|
-
"- `/peer goal
|
|
146
|
+
"- `/peer goal scout [goal-id] [--limit <n>] [--include-closed]` — read-only proactive suggestions for what peers could do next",
|
|
147
|
+
"- `/peer goal task|finding|proposal|handoff|note <goal-id> <summary> [--path <a,b>] [--status done]` — post goal-board events",
|
|
144
148
|
"- `/peer goal claim <goal-id> <task> --mode write --path <a,b> [--ttl-ms <ms>] [--stale-after-ms <ms>]` — lease work without hierarchy",
|
|
145
149
|
"- `/peer goal heartbeat <goal-id> <claim-event-id> [summary] [--ttl-ms <ms>] [--stale-after-ms <ms>]` — refresh a live or stale claim",
|
|
146
150
|
"- `/peer goal release <goal-id> <claim-event-id> [summary]` — release a claimed lane",
|
|
@@ -171,6 +175,7 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
|
|
|
171
175
|
return { ...withAction, objective, constraints: listFlag(flags.constraint || flags.constraints) };
|
|
172
176
|
}
|
|
173
177
|
if (action === "show") return { ...withAction, goalId: rest[0] };
|
|
178
|
+
if (action === "scout") return { ...withAction, goalId: rest[0], limit: positiveIntegerFlag(flags.limit), includeClosed: flagEnabled(flags.includeClosed) };
|
|
174
179
|
if (action === "fanout") {
|
|
175
180
|
const goalId = rest[0];
|
|
176
181
|
const objective = rest.slice(1).join(" ").trim();
|
|
@@ -189,11 +194,11 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
|
|
|
189
194
|
staleAfterMs: positiveIntegerFlag(flags.staleAfterMs),
|
|
190
195
|
};
|
|
191
196
|
}
|
|
192
|
-
if (["task", "finding", "handoff", "note"].includes(action)) {
|
|
197
|
+
if (["task", "finding", "proposal", "propose", "handoff", "note"].includes(action)) {
|
|
193
198
|
const goalId = rest[0];
|
|
194
199
|
const summary = rest.slice(1).join(" ").trim();
|
|
195
200
|
if (!goalId || !summary) return { ...withAction, error: `/peer goal ${action} requires <goal-id> <summary>` };
|
|
196
|
-
return { ...withAction, goalId, eventType: action === "
|
|
201
|
+
return { ...withAction, goalId, eventType: action === "propose" ? "proposal" : action, summary, paths: listFlag(flags.path || flags.paths), severity: stringFlag(flags.severity, undefined), taskId: stringFlag(flags.taskId, undefined), status: stringFlag(flags.status, undefined) };
|
|
197
202
|
}
|
|
198
203
|
if (action === "claim") {
|
|
199
204
|
if (flagEnabled(flags.write) && flags.mode === undefined) flags.mode = "write";
|
package/src/peers/config.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { hostname } from "node:os";
|
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { normalizePeerDescriptor } from "./comms.mjs";
|
|
6
|
+
import { normalizePeerIdleWatcherConfig } from "./idle-watcher.mjs";
|
|
6
7
|
import { PEER_VERSION, peerProtocolMetadata, redactPeerAuditValue } from "./protocol.mjs";
|
|
7
8
|
|
|
8
9
|
export const PEER_SETTINGS_RELATIVE_PATH = ".pi/settings.json";
|
|
@@ -33,6 +34,7 @@ export function parsePeerRuntimeConfig({ settings, peerFile, env } = {}) {
|
|
|
33
34
|
for (const peer of configuredPeers(peerFile?.peers, "peers", warnings)) peersById.set(peer.peerId, { ...(peersById.get(peer.peerId) || {}), ...peer });
|
|
34
35
|
|
|
35
36
|
const manifest = normalizePeerManifest(peerFile?.manifest || settings?.peerMessaging?.manifest || settings?.manifest);
|
|
37
|
+
const idleWatcher = normalizePeerIdleWatcherConfig(peerFile?.idleWatcher || settings?.peerMessaging?.idleWatcher || settings?.idleWatcher, { env });
|
|
36
38
|
const peers = [...peersById.values()].map((peer) => markUnsupportedTransport(normalizePeerDescriptor({ ...manifestDefaults(manifest), ...peer }), warnings));
|
|
37
39
|
const peerFileLocalPeerId = normalizePeerId(peerFile?.localPeerId);
|
|
38
40
|
const settingsPeerMessagingLocalPeerId = normalizePeerId(settings?.peerMessaging?.localPeerId);
|
|
@@ -42,6 +44,7 @@ export function parsePeerRuntimeConfig({ settings, peerFile, env } = {}) {
|
|
|
42
44
|
enabled,
|
|
43
45
|
source: configSource(hasSettings, hasPeerFile),
|
|
44
46
|
manifest,
|
|
47
|
+
idleWatcher,
|
|
45
48
|
localPeerId,
|
|
46
49
|
localPeerIdSource: localPeerIdSource({ peerFileLocalPeerId, settingsPeerMessagingLocalPeerId, settingsLocalPeerId }),
|
|
47
50
|
peers,
|
|
@@ -101,6 +104,7 @@ export function summarizePeerRuntimeConfig(config) {
|
|
|
101
104
|
localPeerProfile: summarizePeerProfile(config.localPeerProfile),
|
|
102
105
|
protocolVersion: config.manifest?.protocolVersion || PEER_VERSION,
|
|
103
106
|
manifest: summarizePeerManifest(config.manifest),
|
|
107
|
+
idleWatcher: summarizePeerIdleWatcher(config.idleWatcher),
|
|
104
108
|
peerCount: Array.isArray(config.peers) ? config.peers.length : 0,
|
|
105
109
|
peers: (config.peers || []).map((peer) => ({
|
|
106
110
|
peerId: peer.peerId,
|
|
@@ -162,6 +166,15 @@ export function summarizePeerProfile(profile = {}) {
|
|
|
162
166
|
return Object.keys(summary).length ? summary : undefined;
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
function summarizePeerIdleWatcher(idleWatcher = {}) {
|
|
170
|
+
return {
|
|
171
|
+
enabled: idleWatcher.enabled !== false,
|
|
172
|
+
intervalMs: idleWatcher.intervalMs,
|
|
173
|
+
cooldownMs: idleWatcher.cooldownMs,
|
|
174
|
+
maxActivationsPerSession: idleWatcher.maxActivationsPerSession,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
function defaultLocalPeerId() {
|
|
166
179
|
return `pi-${sanitizePeerId(hostname()) || "local"}`;
|
|
167
180
|
}
|
package/src/peers/goal-board.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
5
5
|
|
|
6
6
|
export const PEER_GOAL_BOARD_RELATIVE_PATH = ".pi/peer-goals.json";
|
|
7
7
|
|
|
8
|
-
const EVENT_TYPES = new Set(["finding", "task", "claim", "release", "heartbeat", "objection", "resolve", "vote", "handoff", "note"]);
|
|
8
|
+
const EVENT_TYPES = new Set(["finding", "task", "proposal", "claim", "release", "heartbeat", "objection", "resolve", "vote", "handoff", "note"]);
|
|
9
9
|
const BLOCKING_SEVERITIES = new Set(["blocking", "blocker", "critical"]);
|
|
10
10
|
const VOTE_VERDICTS = new Set(["pass", "fail", "pass-with-risks"]);
|
|
11
11
|
const DEFAULT_GOAL_CLAIM_STALE_MS = 45 * 60 * 1000;
|
|
@@ -66,6 +66,7 @@ export async function appendPeerGoalEvent(root, goalId, eventInput = {}) {
|
|
|
66
66
|
const goal = resolveGoal(board, goalId);
|
|
67
67
|
const event = normalizeEvent(eventInput);
|
|
68
68
|
if (event.type === "claim") validateClaim(goal, event);
|
|
69
|
+
if (event.type === "proposal") validateProposal(event);
|
|
69
70
|
if (event.type === "release") validateRelease(goal, event);
|
|
70
71
|
if (event.type === "heartbeat") validateHeartbeat(goal, event);
|
|
71
72
|
goal.events.push(event);
|
|
@@ -172,6 +173,8 @@ export function deriveGoalState(goal, options = {}) {
|
|
|
172
173
|
const blockingObjections = events
|
|
173
174
|
.filter((event) => event.type === "objection" && isBlockingSeverity(event.severity) && !resolvedIds.has(event.id))
|
|
174
175
|
.map(projectEventSummary);
|
|
176
|
+
const proposals = events.filter((event) => event.type === "proposal").map(projectEventSummary);
|
|
177
|
+
const openProposals = proposals.filter((event) => !resolvedIds.has(event.id));
|
|
175
178
|
const votes = events.filter((event) => event.type === "vote").map(projectEventSummary);
|
|
176
179
|
const currentVotes = currentPeerVotes(votes);
|
|
177
180
|
const failedVotes = currentVotes.filter((vote) => vote.verdict === "fail");
|
|
@@ -187,6 +190,8 @@ export function deriveGoalState(goal, options = {}) {
|
|
|
187
190
|
staleClaims,
|
|
188
191
|
releasedClaims,
|
|
189
192
|
blockingObjections,
|
|
193
|
+
proposals,
|
|
194
|
+
openProposals,
|
|
190
195
|
votes,
|
|
191
196
|
currentVotes,
|
|
192
197
|
failedVotes,
|
|
@@ -205,10 +210,62 @@ export function formatPeerGoalList(board) {
|
|
|
205
210
|
if (state.activeClaims.length) bits.push(`${state.activeClaims.length} active claim${state.activeClaims.length === 1 ? "" : "s"}`);
|
|
206
211
|
if (state.staleClaims.length) bits.push(`${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}`);
|
|
207
212
|
if (state.blockingObjections.length) bits.push(`${state.blockingObjections.length} blocker${state.blockingObjections.length === 1 ? "" : "s"}`);
|
|
213
|
+
if (state.openProposals.length) bits.push(`${state.openProposals.length} proposal${state.openProposals.length === 1 ? "" : "s"}`);
|
|
208
214
|
return bits.join(" · ");
|
|
209
215
|
}).join("\n");
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
export function formatPeerGoalScout(board, options = {}) {
|
|
219
|
+
const suggestions = derivePeerGoalScoutSuggestions(board, options);
|
|
220
|
+
if (!suggestions.length) return "No proactive scout suggestions. Open goals look idle-safe or there are no matching goals.";
|
|
221
|
+
const limit = positiveNumber(options.limit) || suggestions.length;
|
|
222
|
+
const lines = ["# Peer Scout", "", "Proactive suggestions (read-only):"];
|
|
223
|
+
for (const suggestion of suggestions.slice(0, limit)) {
|
|
224
|
+
const pathText = suggestion.paths?.length ? ` · paths: ${suggestion.paths.join(", ")}` : "";
|
|
225
|
+
lines.push(`- ${suggestion.priority} · ${suggestion.goalId} · ${suggestion.kind}: ${suggestion.summary}${pathText}`);
|
|
226
|
+
}
|
|
227
|
+
lines.push("", "Next: post one with `/peer goal propose <goal-id> <summary>` or claim safe work with `/peer goal claim <goal-id> <task> --mode read|write --path <path>`. Scout does not mutate the board.");
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function derivePeerGoalScoutSuggestions(board, options = {}) {
|
|
232
|
+
const normalized = normalizeBoard(board);
|
|
233
|
+
const includeClosed = options.includeClosed === true;
|
|
234
|
+
const requestedGoalId = cleanText(options.goalId);
|
|
235
|
+
const goals = Object.values(normalized.goals)
|
|
236
|
+
.filter((goal) => (!requestedGoalId || goal.id === requestedGoalId) && (includeClosed || goal.status !== "closed"))
|
|
237
|
+
.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
|
238
|
+
const suggestions = [];
|
|
239
|
+
for (const goal of goals) {
|
|
240
|
+
const state = deriveGoalState(goal);
|
|
241
|
+
const push = (priority, kind, summary, extra = {}) => suggestions.push(stripEmpty({ goalId: goal.id, priority, kind, summary, ...extra }));
|
|
242
|
+
if (state.blockingObjections.length) {
|
|
243
|
+
push("P0", "blocker", `Resolve ${state.blockingObjections.length} blocking objection${state.blockingObjections.length === 1 ? "" : "s"} before more work.`, { paths: uniqueEventPaths(state.blockingObjections) });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (state.failedVotes.length) {
|
|
247
|
+
push("P0", "failed-vote", `Address failed vote from ${state.failedVotes.map((vote) => vote.peerId || vote.id).join(", ")}.`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (state.staleClaims.length) {
|
|
251
|
+
push("P1", "stale-claim", `Ask owners to heartbeat or release ${state.staleClaims.length} stale claim${state.staleClaims.length === 1 ? "" : "s"}.`, { paths: uniqueEventPaths(state.staleClaims) });
|
|
252
|
+
}
|
|
253
|
+
if (state.openProposals.length) {
|
|
254
|
+
push("P1", "open-proposal", `Triage ${state.openProposals.length} open proposal${state.openProposals.length === 1 ? "" : "s"}; claim one or resolve it if obsolete.`, { paths: uniqueEventPaths(state.openProposals) });
|
|
255
|
+
}
|
|
256
|
+
if (state.readyToClose) {
|
|
257
|
+
push("P1", "close", "Goal satisfies closure gates; close it or record a final note.");
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!state.activeClaims.length && !state.tasks.length && !state.openProposals.length) {
|
|
261
|
+
push("P2", "next-step", "No active work yet; propose a research, review, or implementation lane.");
|
|
262
|
+
} else if (!state.currentVotes.length && !state.activeWriteClaims.length) {
|
|
263
|
+
push("P2", "review", "No current peer vote; ask a peer for read-only review or record a pass/fail vote.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return suggestions;
|
|
267
|
+
}
|
|
268
|
+
|
|
212
269
|
export function formatPeerGoal(goal) {
|
|
213
270
|
const state = goal && Array.isArray(goal.activeClaims) && Array.isArray(goal.expiredClaims) && Array.isArray(goal.staleClaims) ? goal : deriveGoalState(goal);
|
|
214
271
|
const lines = [
|
|
@@ -233,6 +290,10 @@ export function formatPeerGoal(goal) {
|
|
|
233
290
|
lines.push("", "Blocking objections:");
|
|
234
291
|
for (const objection of state.blockingObjections) lines.push(`- ${objection.id} · ${objection.peerId} · ${objection.summary}`);
|
|
235
292
|
}
|
|
293
|
+
if (state.openProposals.length) {
|
|
294
|
+
lines.push("", "Open proposals:");
|
|
295
|
+
for (const proposal of state.openProposals.slice(-8)) lines.push(`- ${proposal.id} · ${proposal.peerId} · ${proposal.summary}${proposal.paths?.length ? ` · ${proposal.paths.join(", ")}` : ""}`);
|
|
296
|
+
}
|
|
236
297
|
if (state.currentVotes.length) {
|
|
237
298
|
lines.push("", "Votes:");
|
|
238
299
|
for (const vote of state.currentVotes.slice(-8)) lines.push(`- ${vote.peerId}: ${vote.verdict}${vote.confidence !== undefined ? ` (${vote.confidence})` : ""}${vote.summary ? ` — ${vote.summary}` : ""}`);
|
|
@@ -257,6 +318,10 @@ function validateClaim(goal, event) {
|
|
|
257
318
|
}
|
|
258
319
|
}
|
|
259
320
|
|
|
321
|
+
function validateProposal(event) {
|
|
322
|
+
if (!event.summary) throw new Error("peer goal proposal requires a summary");
|
|
323
|
+
}
|
|
324
|
+
|
|
260
325
|
function validateRelease(goal, event) {
|
|
261
326
|
if (!event.resolves) throw new Error("peer goal release requires a claim event id");
|
|
262
327
|
const state = deriveGoalState(goal);
|
|
@@ -269,6 +334,10 @@ function validateHeartbeat(goal, event) {
|
|
|
269
334
|
const state = deriveGoalState(goal);
|
|
270
335
|
const claim = state.activeClaims.find((item) => item.id === event.resolves) || state.staleClaims.find((item) => item.id === event.resolves) || state.expiredClaims.find((item) => item.id === event.resolves);
|
|
271
336
|
if (!claim) throw new Error(`peer goal heartbeat target ${event.resolves} is not an active, stale, or expired claim`);
|
|
337
|
+
if (claim.mode === "write") {
|
|
338
|
+
const conflicts = state.activeClaims.filter((item) => item.id !== claim.id && item.mode === "write" && pathsOverlap(claim.paths || [], item.paths || []));
|
|
339
|
+
if (conflicts.length) throw new Error(`heartbeat conflicts with active write claim ${conflicts.map((item) => item.id).join(", ")}`);
|
|
340
|
+
}
|
|
272
341
|
}
|
|
273
342
|
|
|
274
343
|
function validateGoalReadyToClose(state) {
|
|
@@ -410,6 +479,10 @@ function currentPeerVotes(votes) {
|
|
|
410
479
|
return [...byPeer.values()];
|
|
411
480
|
}
|
|
412
481
|
|
|
482
|
+
function uniqueEventPaths(events) {
|
|
483
|
+
return [...new Set(events.flatMap((event) => Array.isArray(event.paths) ? event.paths : []))];
|
|
484
|
+
}
|
|
485
|
+
|
|
413
486
|
async function updatePeerGoalBoard(root, updater) {
|
|
414
487
|
const path = goalBoardPath(root);
|
|
415
488
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -460,11 +533,18 @@ function goalBoardPath(root) {
|
|
|
460
533
|
}
|
|
461
534
|
|
|
462
535
|
function pathsOverlap(a, b) {
|
|
463
|
-
return a.some((left) => b.some((right) => left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)));
|
|
536
|
+
return a.some((left) => b.some((right) => left === "." || right === "." || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)));
|
|
464
537
|
}
|
|
465
538
|
|
|
466
539
|
function normalizePaths(value) {
|
|
467
|
-
return [...new Set(normalizeList(value).map(
|
|
540
|
+
return [...new Set(normalizeList(value).map(normalizePath).filter(Boolean))];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizePath(value) {
|
|
544
|
+
let path = cleanText(value).replace(/\/+/g, "/");
|
|
545
|
+
if (path === "" || path === "." || path === "/") return ".";
|
|
546
|
+
path = path.replace(/^\.\//, "").replace(/\/$/, "");
|
|
547
|
+
return path === "" || path === "." || path === "/" ? "." : path;
|
|
468
548
|
}
|
|
469
549
|
|
|
470
550
|
function normalizeList(value) {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { derivePeerGoalScoutSuggestions, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PEER_IDLE_WATCHER_INTERVAL_MS = 15_000;
|
|
4
|
+
export const DEFAULT_PEER_IDLE_WATCHER_COOLDOWN_MS = 5 * 60 * 1000;
|
|
5
|
+
export const DEFAULT_PEER_IDLE_WATCHER_MAX_PER_SESSION = 20;
|
|
6
|
+
|
|
7
|
+
const FALSE_VALUES = new Set(["0", "false", "off", "no", "disabled"]);
|
|
8
|
+
const TRUE_VALUES = new Set(["1", "true", "on", "yes", "enabled"]);
|
|
9
|
+
const DEFAULT_ALLOWED_KINDS = ["blocker", "failed-vote", "stale-claim", "open-proposal", "close", "next-step", "review"];
|
|
10
|
+
|
|
11
|
+
export function normalizePeerIdleWatcherConfig(input = {}, options = {}) {
|
|
12
|
+
const env = options.env || process.env;
|
|
13
|
+
const source = plainObject(input) ? input : {};
|
|
14
|
+
const envEnabled = parseBoolean(env.PI_PEER_IDLE_WATCHER);
|
|
15
|
+
return {
|
|
16
|
+
enabled: envEnabled ?? (source.enabled !== false),
|
|
17
|
+
intervalMs: positiveInteger(env.PI_PEER_IDLE_WATCHER_INTERVAL_MS) || positiveInteger(source.intervalMs) || DEFAULT_PEER_IDLE_WATCHER_INTERVAL_MS,
|
|
18
|
+
cooldownMs: positiveInteger(env.PI_PEER_IDLE_WATCHER_COOLDOWN_MS) || positiveInteger(source.cooldownMs) || DEFAULT_PEER_IDLE_WATCHER_COOLDOWN_MS,
|
|
19
|
+
maxActivationsPerSession: positiveInteger(source.maxActivationsPerSession) || DEFAULT_PEER_IDLE_WATCHER_MAX_PER_SESSION,
|
|
20
|
+
includeClosed: source.includeClosed === true,
|
|
21
|
+
allowedKinds: normalizeAllowedKinds(source.allowedKinds),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createPeerIdleWatcher(options = {}) {
|
|
26
|
+
const runtime = options.runtime;
|
|
27
|
+
const pi = options.pi;
|
|
28
|
+
const activeContext = typeof options.activeContext === "function" ? options.activeContext : () => undefined;
|
|
29
|
+
const refresh = typeof options.refresh === "function" ? options.refresh : async () => {};
|
|
30
|
+
const loadBoard = options.loadBoard || ((root) => loadPeerGoalBoard(root));
|
|
31
|
+
const now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
32
|
+
const config = normalizePeerIdleWatcherConfig(options.config || runtime?.config?.idleWatcher || {}, { env: options.env });
|
|
33
|
+
const state = {
|
|
34
|
+
running: false,
|
|
35
|
+
timer: undefined,
|
|
36
|
+
activationCount: 0,
|
|
37
|
+
lastActivationAtByKey: new Map(),
|
|
38
|
+
checking: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function check(reason = "timer") {
|
|
42
|
+
if (!runtime?.enabled || !config.enabled || state.checking) return { activated: false, reason: "disabled" };
|
|
43
|
+
state.checking = true;
|
|
44
|
+
try {
|
|
45
|
+
const ctx = activeContext();
|
|
46
|
+
const idle = isContextIdle(ctx);
|
|
47
|
+
if (!idle.ok) return { activated: false, reason: idle.reason };
|
|
48
|
+
if (state.activationCount >= config.maxActivationsPerSession) return { activated: false, reason: "activation limit reached" };
|
|
49
|
+
if (runtime?.pendingInboundCount?.() > 0) {
|
|
50
|
+
const nudged = runtime?.nudgeInboundIfIdle?.({ reason: "idle-watcher", cooldownMs: Math.min(activationNudgeCooldownMs(config), config.cooldownMs) });
|
|
51
|
+
if (nudged?.ok) {
|
|
52
|
+
state.activationCount = (state.activationCount || 0) + 1;
|
|
53
|
+
await refresh(ctx).catch(() => {});
|
|
54
|
+
return { activated: true, activation: { kind: "inbound-nudge", messageId: nudged.messageId, conversationId: nudged.conversationId, activationAttempts: nudged.activationAttempts } };
|
|
55
|
+
}
|
|
56
|
+
return { activated: false, reason: nudged?.reason || "inbound peer task active" };
|
|
57
|
+
}
|
|
58
|
+
const messages = runtime?.comms?.listMessages ? await runtime.comms.listMessages() : [];
|
|
59
|
+
const pendingMessages = messages.filter((message) => ["queued", "running"].includes(message.status));
|
|
60
|
+
if (pendingMessages.length) return { activated: false, reason: "peer messages pending" };
|
|
61
|
+
|
|
62
|
+
const board = await loadBoard(runtime.cwd || ctx?.cwd || process.cwd());
|
|
63
|
+
const activation = derivePeerIdleActivation(board, {
|
|
64
|
+
localPeerId: runtime.localPeerId,
|
|
65
|
+
config,
|
|
66
|
+
state,
|
|
67
|
+
nowMs: now(),
|
|
68
|
+
});
|
|
69
|
+
if (!activation) return { activated: false, reason: "no idle activation" };
|
|
70
|
+
|
|
71
|
+
const prompt = buildPeerIdleActivationPrompt(activation, { localPeerId: runtime.localPeerId });
|
|
72
|
+
pi.sendMessage({
|
|
73
|
+
customType: options.messageType || "pi-peer",
|
|
74
|
+
content: `Idle watcher activated (${reason}): ${activation.kind} for ${activation.goalId}\n\n${prompt}`,
|
|
75
|
+
display: true,
|
|
76
|
+
details: { kind: "peer_idle_activation", activation },
|
|
77
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
78
|
+
markPeerIdleActivation(state, activation, now());
|
|
79
|
+
await refresh(ctx).catch(() => {});
|
|
80
|
+
return { activated: true, activation };
|
|
81
|
+
} finally {
|
|
82
|
+
state.checking = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
config,
|
|
88
|
+
state,
|
|
89
|
+
start() {
|
|
90
|
+
if (state.running || !config.enabled || !runtime?.enabled) return false;
|
|
91
|
+
state.running = true;
|
|
92
|
+
state.timer = setInterval(() => {
|
|
93
|
+
void check("timer").catch(() => {});
|
|
94
|
+
}, config.intervalMs);
|
|
95
|
+
state.timer.unref?.();
|
|
96
|
+
// Let a freshly-started idle worker notice existing board work without waiting a full interval.
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
if (state.running) void check("startup").catch(() => {});
|
|
99
|
+
}, Math.min(1_000, config.intervalMs)).unref?.();
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
stop() {
|
|
103
|
+
state.running = false;
|
|
104
|
+
clearInterval(state.timer);
|
|
105
|
+
state.timer = undefined;
|
|
106
|
+
},
|
|
107
|
+
check,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function derivePeerIdleActivation(board, options = {}) {
|
|
112
|
+
const config = normalizePeerIdleWatcherConfig(options.config || {});
|
|
113
|
+
if (!config.enabled) return undefined;
|
|
114
|
+
const suggestions = derivePeerGoalScoutSuggestions(board, { includeClosed: config.includeClosed });
|
|
115
|
+
const allowedKinds = new Set(config.allowedKinds || DEFAULT_ALLOWED_KINDS);
|
|
116
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
117
|
+
for (const suggestion of suggestions) {
|
|
118
|
+
if (!allowedKinds.has(suggestion.kind)) continue;
|
|
119
|
+
const activation = normalizeActivation(suggestion, options.localPeerId);
|
|
120
|
+
if (!activation) continue;
|
|
121
|
+
if (isActivationCoolingDown(options.state, activation, config, nowMs)) continue;
|
|
122
|
+
return activation;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function markPeerIdleActivation(state, activation, nowMs = Date.now()) {
|
|
128
|
+
if (!state || !activation) return false;
|
|
129
|
+
state.activationCount = (state.activationCount || 0) + 1;
|
|
130
|
+
if (!state.lastActivationAtByKey) state.lastActivationAtByKey = new Map();
|
|
131
|
+
state.lastActivationAtByKey.set(peerIdleActivationKey(activation), nowMs);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildPeerIdleActivationPrompt(activation, options = {}) {
|
|
136
|
+
const peerId = options.localPeerId || "this-peer";
|
|
137
|
+
const paths = activation.paths?.length ? `\nPaths: ${activation.paths.join(", ")}` : "";
|
|
138
|
+
return `[Pi peer idle watcher]\nYou are local peer '${peerId}' and Pi is idle. A proactive goal-board scout suggestion is available.\n\nGoal: ${activation.goalId}\nSuggestion: ${activation.kind} (${activation.priority}) — ${activation.summary}${paths}\n\nInstructions:\n- First inspect current state with peer_get id '${activation.goalId}'.\n- If useful, take one small safe action: post a proposal/finding/vote, claim a read-only review lane, or claim write work only when you intend to edit and can name the paths.\n- Do not duplicate active claims or proposals. If the board is no longer actionable, say so briefly and stop.\n- For write work, respect goal-board claims and end with the required peer handoff sections.\n- Keep the response concise.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function peerIdleActivationKey(activation = {}) {
|
|
142
|
+
return [activation.goalId, activation.kind, activation.summary, ...(activation.paths || [])].join("|");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isActivationCoolingDown(state, activation, config, nowMs) {
|
|
146
|
+
const last = state?.lastActivationAtByKey?.get?.(peerIdleActivationKey(activation));
|
|
147
|
+
return Number.isFinite(last) && nowMs - last < config.cooldownMs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeActivation(suggestion = {}, localPeerId) {
|
|
151
|
+
if (!suggestion.goalId || !suggestion.kind || !suggestion.summary) return undefined;
|
|
152
|
+
return {
|
|
153
|
+
goalId: suggestion.goalId,
|
|
154
|
+
priority: suggestion.priority || "P2",
|
|
155
|
+
kind: suggestion.kind,
|
|
156
|
+
summary: suggestion.summary,
|
|
157
|
+
paths: Array.isArray(suggestion.paths) ? suggestion.paths.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()) : [],
|
|
158
|
+
peerId: localPeerId,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isContextIdle(ctx) {
|
|
163
|
+
if (!ctx) return { ok: false, reason: "no active session context" };
|
|
164
|
+
if (typeof ctx.isIdle === "function" && !ctx.isIdle()) return { ok: false, reason: "agent busy" };
|
|
165
|
+
if (typeof ctx.hasPendingMessages === "function" && ctx.hasPendingMessages()) return { ok: false, reason: "pending local messages" };
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function activationNudgeCooldownMs(config = {}) {
|
|
170
|
+
return Math.max(5_000, Math.floor((config.intervalMs || DEFAULT_PEER_IDLE_WATCHER_INTERVAL_MS) * 2));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeAllowedKinds(value) {
|
|
174
|
+
if (!Array.isArray(value)) return DEFAULT_ALLOWED_KINDS;
|
|
175
|
+
const kinds = value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim());
|
|
176
|
+
return kinds.length ? [...new Set(kinds)] : DEFAULT_ALLOWED_KINDS;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseBoolean(value) {
|
|
180
|
+
if (typeof value !== "string") return undefined;
|
|
181
|
+
const normalized = value.trim().toLowerCase();
|
|
182
|
+
if (TRUE_VALUES.has(normalized)) return true;
|
|
183
|
+
if (FALSE_VALUES.has(normalized)) return false;
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function positiveInteger(value) {
|
|
188
|
+
const number = Number(value);
|
|
189
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function plainObject(value) {
|
|
193
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
194
|
+
}
|
|
@@ -7,6 +7,7 @@ export const PI_PEER_INBOUND_CUSTOM_TYPE = "pi-peer-inbound";
|
|
|
7
7
|
export function createInboundPromptBridge(options = {}) {
|
|
8
8
|
const pi = options.pi;
|
|
9
9
|
const responseTimeoutMs = Number.isInteger(options.responseTimeoutMs) ? options.responseTimeoutMs : 30 * 60 * 1000;
|
|
10
|
+
const activationNudgeCooldownMs = Number.isInteger(options.activationNudgeCooldownMs) ? options.activationNudgeCooldownMs : 30_000;
|
|
10
11
|
const queue = [];
|
|
11
12
|
let activeEntry;
|
|
12
13
|
|
|
@@ -19,12 +20,7 @@ export function createInboundPromptBridge(options = {}) {
|
|
|
19
20
|
startActiveTimer(entry);
|
|
20
21
|
|
|
21
22
|
try {
|
|
22
|
-
|
|
23
|
-
customType: PI_PEER_INBOUND_CUSTOM_TYPE,
|
|
24
|
-
content: renderInboundPeerPrompt(entry.envelope, { responderProfile: options.responderProfile, homeDir: options.homeDir }),
|
|
25
|
-
display: true,
|
|
26
|
-
envelope: summarizeEnvelope(entry.envelope),
|
|
27
|
-
}, { deliverAs: "followUp", triggerTurn: true });
|
|
23
|
+
sendActiveEntryToPi(entry, "initial");
|
|
28
24
|
} catch (error) {
|
|
29
25
|
activeEntry = undefined;
|
|
30
26
|
settleEntry(entry, { status: "ERROR", summary: error?.message || String(error) });
|
|
@@ -32,6 +28,23 @@ export function createInboundPromptBridge(options = {}) {
|
|
|
32
28
|
}
|
|
33
29
|
}
|
|
34
30
|
|
|
31
|
+
function sendActiveEntryToPi(entry, reason) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
entry.activationAttempts = (entry.activationAttempts || 0) + 1;
|
|
34
|
+
entry.lastNudgeAt = now;
|
|
35
|
+
if (!entry.activatedAt) entry.activatedAt = now;
|
|
36
|
+
pi.sendMessage({
|
|
37
|
+
customType: PI_PEER_INBOUND_CUSTOM_TYPE,
|
|
38
|
+
content: renderInboundPeerPrompt(entry.envelope, { responderProfile: options.responderProfile, homeDir: options.homeDir }),
|
|
39
|
+
display: true,
|
|
40
|
+
envelope: summarizeEnvelope(entry.envelope),
|
|
41
|
+
details: {
|
|
42
|
+
activationReason: reason,
|
|
43
|
+
activationAttempts: entry.activationAttempts,
|
|
44
|
+
},
|
|
45
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
function startActiveTimer(entry) {
|
|
36
49
|
entry.timer = setTimeout(() => {
|
|
37
50
|
if (activeEntry !== entry || entry.settled) return;
|
|
@@ -89,6 +102,34 @@ export function createInboundPromptBridge(options = {}) {
|
|
|
89
102
|
return { ok: true, messageId: entry.messageId, conversationId: entry.conversationId, progress };
|
|
90
103
|
},
|
|
91
104
|
|
|
105
|
+
nudgeActive(input = {}) {
|
|
106
|
+
const entry = activeEntry;
|
|
107
|
+
if (!entry || entry.settled) return { ok: false, reason: "no active inbound peer task" };
|
|
108
|
+
const cooldownMs = Number.isInteger(input.cooldownMs) ? input.cooldownMs : activationNudgeCooldownMs;
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
if (entry.lastNudgeAt && now - entry.lastNudgeAt < cooldownMs) {
|
|
111
|
+
return { ok: false, reason: "inbound activation nudge cooldown", messageId: entry.messageId, conversationId: entry.conversationId, activationAttempts: entry.activationAttempts || 0 };
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
sendActiveEntryToPi(entry, input.reason || "idle-nudge");
|
|
115
|
+
return { ok: true, messageId: entry.messageId, conversationId: entry.conversationId, activationAttempts: entry.activationAttempts || 0 };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { ok: false, reason: error?.message || String(error), messageId: entry.messageId, conversationId: entry.conversationId, activationAttempts: entry.activationAttempts || 0 };
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
activeState() {
|
|
122
|
+
const entry = activeEntry;
|
|
123
|
+
return entry && !entry.settled ? {
|
|
124
|
+
messageId: entry.messageId,
|
|
125
|
+
conversationId: entry.conversationId,
|
|
126
|
+
activatedAt: entry.activatedAt,
|
|
127
|
+
lastNudgeAt: entry.lastNudgeAt,
|
|
128
|
+
activationAttempts: entry.activationAttempts || 0,
|
|
129
|
+
queuedCount: queue.length,
|
|
130
|
+
} : { queuedCount: queue.length };
|
|
131
|
+
},
|
|
132
|
+
|
|
92
133
|
pendingCount() {
|
|
93
134
|
return queue.length + (activeEntry && !activeEntry.settled ? 1 : 0);
|
|
94
135
|
},
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
|
|
8
8
|
import { PEER_VERSION, assertValidPeerEnvelope, createPeerEnvelope, isPeerProtocolCompatible, normalizePeerMessageResponseBody, peerProtocolMetadata, redactPeerAuditValue, resolvePeerAuthToken, validatePeerEnvelope } from "./protocol.mjs";
|
|
@@ -70,6 +70,7 @@ export function createLocalPeerEndpoint(options = {}) {
|
|
|
70
70
|
? options.presenceHeartbeatIntervalMs
|
|
71
71
|
: DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS;
|
|
72
72
|
const authToken = resolveEndpointAuthToken(options);
|
|
73
|
+
let projectScope;
|
|
73
74
|
let server;
|
|
74
75
|
let descriptor;
|
|
75
76
|
let presenceHeartbeatTimer;
|
|
@@ -117,6 +118,7 @@ export function createLocalPeerEndpoint(options = {}) {
|
|
|
117
118
|
await chmod(socketDir, 0o700).catch(() => {});
|
|
118
119
|
}
|
|
119
120
|
if (socketPath && existsSync(socketPath)) await rm(socketPath, { force: true });
|
|
121
|
+
projectScope = options.projectScope || await derivePeerProjectScope(options.cwd);
|
|
120
122
|
server = net.createServer((socket) => {
|
|
121
123
|
sockets.add(socket);
|
|
122
124
|
socket.once("close", () => sockets.delete(socket));
|
|
@@ -144,6 +146,7 @@ export function createLocalPeerEndpoint(options = {}) {
|
|
|
144
146
|
maxHopCount: Number.isInteger(options.maxHopCount) ? options.maxHopCount : 1,
|
|
145
147
|
pid: process.pid,
|
|
146
148
|
cwd: options.cwd,
|
|
149
|
+
projectScope,
|
|
147
150
|
sessionId: options.sessionId,
|
|
148
151
|
role: safeDescriptorText(options.role),
|
|
149
152
|
persona: safeDescriptorText(options.persona),
|
|
@@ -185,6 +188,7 @@ export async function discoverLocalPeerEndpoints(options = {}) {
|
|
|
185
188
|
const discoveryDir = resolve(options.discoveryDir || LOCAL_PEER_DISCOVERY_DIR);
|
|
186
189
|
const excludePeerId = options.excludePeerId;
|
|
187
190
|
const maxAgeMs = Number.isInteger(options.maxAgeMs) ? options.maxAgeMs : 10 * 60 * 1000;
|
|
191
|
+
const projectScope = options.projectScope || (options.cwd ? await derivePeerProjectScope(options.cwd) : undefined);
|
|
188
192
|
let names;
|
|
189
193
|
try {
|
|
190
194
|
names = await readdir(discoveryDir);
|
|
@@ -203,6 +207,10 @@ export async function discoverLocalPeerEndpoints(options = {}) {
|
|
|
203
207
|
if (descriptor.transport !== "coms" || descriptor.status !== "active") continue;
|
|
204
208
|
if (!descriptor.socketPath && !descriptor.pipeName) continue;
|
|
205
209
|
if (!descriptor.updatedAt) continue;
|
|
210
|
+
if (projectScope) {
|
|
211
|
+
const descriptorScope = await descriptorProjectScope(descriptor);
|
|
212
|
+
if (!descriptorScope || descriptorScope !== projectScope) continue;
|
|
213
|
+
}
|
|
206
214
|
const updatedAtMs = Date.parse(descriptor.updatedAt);
|
|
207
215
|
if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > maxAgeMs) continue;
|
|
208
216
|
if (!Number.isInteger(descriptor.pid) || !processAlive(descriptor.pid)) continue;
|
|
@@ -218,6 +226,7 @@ export async function discoverLocalPeerEndpoints(options = {}) {
|
|
|
218
226
|
compatible: isPeerProtocolCompatible(descriptor),
|
|
219
227
|
capabilities: descriptor.capabilities || {},
|
|
220
228
|
cwd: descriptor.cwd,
|
|
229
|
+
projectScope: descriptor.projectScope,
|
|
221
230
|
role: descriptor.role,
|
|
222
231
|
persona: descriptor.persona,
|
|
223
232
|
sessionId: descriptor.sessionId,
|
|
@@ -230,6 +239,47 @@ export async function discoverLocalPeerEndpoints(options = {}) {
|
|
|
230
239
|
return peers;
|
|
231
240
|
}
|
|
232
241
|
|
|
242
|
+
export async function derivePeerProjectScope(cwd) {
|
|
243
|
+
const start = await canonicalPath(cwd || process.cwd());
|
|
244
|
+
const root = await findGitRoot(start);
|
|
245
|
+
return root || start;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function descriptorProjectScope(descriptor = {}) {
|
|
249
|
+
if (typeof descriptor.projectScope === "string" && descriptor.projectScope.trim()) return canonicalPath(descriptor.projectScope);
|
|
250
|
+
if (typeof descriptor.cwd === "string" && descriptor.cwd.trim()) return derivePeerProjectScope(descriptor.cwd);
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function findGitRoot(start) {
|
|
255
|
+
let current = resolve(start || process.cwd());
|
|
256
|
+
for (;;) {
|
|
257
|
+
if (await hasGitMarker(current)) return current;
|
|
258
|
+
const parent = dirname(current);
|
|
259
|
+
if (parent === current) return undefined;
|
|
260
|
+
current = parent;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function hasGitMarker(dir) {
|
|
265
|
+
try {
|
|
266
|
+
const marker = resolve(dir, ".git");
|
|
267
|
+
const info = await stat(marker);
|
|
268
|
+
return info.isDirectory() || info.isFile();
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function canonicalPath(path) {
|
|
275
|
+
const resolved = resolve(path || process.cwd());
|
|
276
|
+
try {
|
|
277
|
+
return await realpath(resolved);
|
|
278
|
+
} catch {
|
|
279
|
+
return resolved;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
233
283
|
async function sendEnvelopeToEndpoint(envelope, peer, options) {
|
|
234
284
|
const socket = net.createConnection(peer.pipeName || peer.socketPath);
|
|
235
285
|
const timeoutMs = options.timeoutMs;
|
package/src/peers/runtime.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { InMemoryPeerTransport, MemoryPeerRegistry, createPeerComms } from "./co
|
|
|
4
4
|
import { applyLocalPeerIdOverride, loadLocalPeerProfile, loadPeerRuntimeConfig, summarizePeerRuntimeConfig } from "./config.mjs";
|
|
5
5
|
import { deriveGoalState, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
6
6
|
import { createInboundPromptBridge } from "./inbound-bridge.mjs";
|
|
7
|
-
import { LocalPeerTransport, createLocalPeerEndpoint, discoverLocalPeerEndpoints } from "./local-transport.mjs";
|
|
7
|
+
import { LocalPeerTransport, createLocalPeerEndpoint, derivePeerProjectScope, discoverLocalPeerEndpoints } from "./local-transport.mjs";
|
|
8
8
|
import { createPeerMessageStore } from "./message-store.mjs";
|
|
9
9
|
import { redactPeerAuditValue } from "./protocol.mjs";
|
|
10
10
|
import { collectPeerRuntimeStatus, deriveFanoutSuggestion } from "./status.mjs";
|
|
@@ -48,6 +48,7 @@ export async function createPeerRuntime(cwd, options = {}) {
|
|
|
48
48
|
messageStore,
|
|
49
49
|
persistedState: persistedMessages,
|
|
50
50
|
});
|
|
51
|
+
const projectScope = options.projectScope || await derivePeerProjectScope(cwd);
|
|
51
52
|
let localEndpoint;
|
|
52
53
|
let discoveredPeerIds = new Set();
|
|
53
54
|
|
|
@@ -59,12 +60,13 @@ export async function createPeerRuntime(cwd, options = {}) {
|
|
|
59
60
|
summary: { ...summarizePeerRuntimeConfig(config), localPeerId },
|
|
60
61
|
localPeerId,
|
|
61
62
|
cwd,
|
|
63
|
+
projectScope,
|
|
62
64
|
get localEndpoint() {
|
|
63
65
|
return localEndpoint?.descriptor;
|
|
64
66
|
},
|
|
65
67
|
async refreshLocalPeers() {
|
|
66
68
|
if (!config.enabled) return [];
|
|
67
|
-
const peers = await discoverLocalPeerEndpoints({ discoveryDir: options.discoveryDir, excludePeerId: localPeerId });
|
|
69
|
+
const peers = await discoverLocalPeerEndpoints({ discoveryDir: options.discoveryDir, excludePeerId: localPeerId, projectScope });
|
|
68
70
|
const nextDiscoveredPeerIds = new Set(peers.map((peer) => peer.peerId));
|
|
69
71
|
for (const peerId of discoveredPeerIds) {
|
|
70
72
|
if (nextDiscoveredPeerIds.has(peerId)) continue;
|
|
@@ -82,6 +84,7 @@ export async function createPeerRuntime(cwd, options = {}) {
|
|
|
82
84
|
const endpoint = createLocalPeerEndpoint({
|
|
83
85
|
peerId: localPeerId,
|
|
84
86
|
cwd,
|
|
87
|
+
projectScope,
|
|
85
88
|
sessionId: options.sessionId || ctx.sessionId,
|
|
86
89
|
role: localPeerProfile.role || options.role,
|
|
87
90
|
persona: localPeerProfile.persona || options.persona,
|
|
@@ -111,6 +114,15 @@ export async function createPeerRuntime(cwd, options = {}) {
|
|
|
111
114
|
recordInboundProgress(progress) {
|
|
112
115
|
return inboundBridge ? inboundBridge.recordProgress(progress) : { ok: false, reason: "no active inbound peer task" };
|
|
113
116
|
},
|
|
117
|
+
pendingInboundCount() {
|
|
118
|
+
return inboundBridge ? inboundBridge.pendingCount() : 0;
|
|
119
|
+
},
|
|
120
|
+
nudgeInboundIfIdle(input) {
|
|
121
|
+
return inboundBridge ? inboundBridge.nudgeActive(input) : { ok: false, reason: "no active inbound peer task" };
|
|
122
|
+
},
|
|
123
|
+
activeInboundState() {
|
|
124
|
+
return inboundBridge ? inboundBridge.activeState() : { queuedCount: 0 };
|
|
125
|
+
},
|
|
114
126
|
async shutdown() {
|
|
115
127
|
if (inboundBridge) {
|
|
116
128
|
inboundBridge.dispose("Peer runtime shutting down");
|
package/src/peers/status.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export function derivePeerRuntimeStatus(runtime = {}, options = {}) {
|
|
|
28
28
|
source: runtime.source || runtime.summary?.source || "none",
|
|
29
29
|
localPeerId: runtime.localPeerId || runtime.summary?.localPeerId || "unknown",
|
|
30
30
|
localPeerIdSource: runtime.summary?.localPeerIdSource || runtime.config?.localPeerIdSource,
|
|
31
|
+
projectScope: runtime.projectScope,
|
|
31
32
|
protocolVersion: endpoint?.protocolVersion || runtime.summary?.protocolVersion || runtime.config?.manifest?.protocolVersion,
|
|
32
33
|
localTrust: endpoint?.trust || runtime.config?.manifest?.trust || runtime.summary?.manifest?.trust,
|
|
33
34
|
localCapabilities,
|
|
@@ -56,7 +57,7 @@ export function formatPeerStatusLines(status = {}) {
|
|
|
56
57
|
const capsText = capabilitySummary(status.localCapabilities);
|
|
57
58
|
const lines = [
|
|
58
59
|
line("state", color, `🔗 peers ${enabledText} · id ${status.localPeerId || "unknown"}${profileText ? ` · ${profileText}` : ""}${protocolText} · source ${status.source || "none"}`),
|
|
59
|
-
line("endpoint", status.endpointStatus === "listening" ? "success" : status.enabled ? "warning" : "muted", `endpoint ${status.endpointStatus || "unknown"} · auth ${status.authStatus || "unknown"}`),
|
|
60
|
+
line("endpoint", status.endpointStatus === "listening" ? "success" : status.enabled ? "warning" : "muted", `endpoint ${status.endpointStatus || "unknown"} · auth ${status.authStatus || "unknown"}${status.projectScope ? " · repo scoped" : ""}`),
|
|
60
61
|
line("peers", status.activeCount > 0 ? "accent" : "muted", `peers discovered ${status.discoveredCount || 0} · active ${status.activeCount || 0} · configured ${status.configuredPeers || 0}${capsText ? ` · caps ${capsText}` : ""}`),
|
|
61
62
|
line("messages", status.pendingCount > 0 ? "accent" : "muted", `messages pending ${status.pendingCount || 0}`),
|
|
62
63
|
];
|