@cryptolibertus/pi-peer 0.3.3 → 0.3.6

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 CHANGED
@@ -15,8 +15,11 @@ pi install ./packages/pi-peer
15
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
22
+ - `pi-peer-publish` skill for safe npm release checks, version bumping, tag push, publish, and verification
20
23
 
21
24
  ## Setup and health checks
22
25
 
@@ -28,7 +31,7 @@ pi install ./packages/pi-peer
28
31
  /peer cancel <message-id> "superseded"
29
32
  ```
30
33
 
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.
34
+ `/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. List-style flags such as `--peer`, `--path`, and `--claim` accept comma-separated values or repeated flags.
32
35
 
33
36
  ## Flat goal board
34
37
 
@@ -58,15 +61,61 @@ The board is stored locally at `.pi/peer-goals.json`; outbound message snapshots
58
61
 
59
62
  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.
60
63
 
64
+ ## Idle watcher
65
+
66
+ 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:
67
+
68
+ 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.
69
+ 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.
70
+
71
+ Configuration can be placed in `.pi/peers.json` as `idleWatcher` or in `.pi/settings.json` under `peerMessaging.idleWatcher`:
72
+
73
+ ```json
74
+ {
75
+ "idleWatcher": {
76
+ "enabled": true,
77
+ "intervalMs": 15000,
78
+ "cooldownMs": 300000,
79
+ "maxActivationsPerSession": 20
80
+ }
81
+ }
82
+ ```
83
+
84
+ 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.
85
+
61
86
  ## Package checks
62
87
 
63
88
  ```bash
64
- npm --prefix packages/pi-peer test
65
- npm --prefix packages/pi-peer run check:pack
66
- npm --prefix packages/pi-peer run smoke:pack
67
- npm --prefix packages/pi-peer run check
89
+ npm test
90
+ npm run check:pack
91
+ npm run smoke:pack
92
+ npm run check
68
93
  ```
69
94
 
95
+ ## Publish to npm
96
+
97
+ This package includes a `pi-peer-publish` Pi skill. Ask Pi to use it, or run `/skill:pi-peer-publish`, when you want an agent-guided release with safety checks, version bumping, tag push, publish, and npm verification.
98
+
99
+ Use this manual release workflow after landing package changes on `main`:
100
+
101
+ ```bash
102
+ # Keep local peer runtime state out of release commits.
103
+ echo ".pi/" >> .git/info/exclude
104
+
105
+ # Verify the package before changing the version.
106
+ git status --short
107
+ npm run check
108
+
109
+ # Bump, commit, and tag the next patch version.
110
+ npm version patch
111
+
112
+ # Push the release commit and tag, then publish.
113
+ git push origin main --tags
114
+ npm publish --access public
115
+ ```
116
+
117
+ 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`.
118
+
70
119
  ## Notes
71
120
 
72
121
  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.
@@ -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,10 +44,13 @@ 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
- await refreshPeerUi(ctx, await runtimeFor(pi, ctx.cwd));
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
 
@@ -581,6 +586,7 @@ async function resetRuntimeFor(cwd?: string) {
581
586
  const pending = runtimeByCwd.get(key);
582
587
  runtimeByCwd.delete(key);
583
588
  const runtime = await pending?.catch(() => undefined);
589
+ runtime?.__peerIdleWatcher?.stop?.();
584
590
  await runtime?.dispose?.();
585
591
  }
586
592
 
@@ -593,6 +599,29 @@ function attachPeerUi(runtime: any, activeContext: () => any, refresh: (ctx: any
593
599
  });
594
600
  }
595
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
+
596
625
  function ensureEnabled(runtime: any) {
597
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.");
598
627
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptolibertus/pi-peer",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
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",
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "files": [
15
15
  "extensions",
16
+ "skills",
16
17
  "src",
17
18
  "README.md",
18
19
  "LICENSE"
@@ -32,6 +33,9 @@
32
33
  "pi": {
33
34
  "extensions": [
34
35
  "extensions/pi-peer/index.ts"
36
+ ],
37
+ "skills": [
38
+ "skills"
35
39
  ]
36
40
  },
37
41
  "engines": {
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: pi-peer-publish
3
+ description: Publish @cryptolibertus/pi-peer to npm safely. Use when the user asks to publish, release, npm publish, bump the package version, or verify/package/push an npm release for this pi-peer package.
4
+ ---
5
+
6
+ # Pi peer publish
7
+
8
+ This skill publishes `@cryptolibertus/pi-peer` from the repository root.
9
+
10
+ ## Safety rules
11
+
12
+ - Never publish with uncommitted source changes unless the user explicitly asks to include them and they have been committed first.
13
+ - Never commit `.pi/`, local peer runtime state, npm debug logs, or generated tarballs.
14
+ - Stop before `npm publish` unless the user has explicitly asked to publish now in the current conversation. If they only asked to prepare a release, stop after the dry run and show the exact publish command.
15
+ - If `npm whoami` fails, stop and ask the user to authenticate with `npm login` or configure an automation token outside the chat.
16
+ - For scoped packages, always publish with `--access public` unless `package.json` says otherwise.
17
+
18
+ ## Workflow
19
+
20
+ 1. Inspect release state:
21
+ ```bash
22
+ git status --short --branch
23
+ git remote get-url origin
24
+ git branch --show-current
25
+ npm whoami
26
+ npm view @cryptolibertus/pi-peer version --json
27
+ node -p "require('./package.json').version"
28
+ ```
29
+
30
+ 2. Ensure local peer state will not be committed:
31
+ ```bash
32
+ grep -qxF '.pi/' .git/info/exclude 2>/dev/null || echo '.pi/' >> .git/info/exclude
33
+ ```
34
+
35
+ 3. Verify package quality and tarball contents:
36
+ ```bash
37
+ npm run check
38
+ npm pack --dry-run
39
+ ```
40
+
41
+ 4. Choose the version bump:
42
+ - Default to `patch` for small fixes and docs.
43
+ - Use `minor` only for new user-facing capabilities.
44
+ - Use `major` only for breaking changes, and ask the user first.
45
+
46
+ 5. Bump, commit, and tag using npm so `package.json`, `package-lock.json` if present, and the git tag stay consistent:
47
+ ```bash
48
+ npm version patch
49
+ ```
50
+
51
+ 6. Push the release commit and tag:
52
+ ```bash
53
+ git push origin HEAD --follow-tags
54
+ ```
55
+
56
+ 7. Publish:
57
+ ```bash
58
+ npm publish --access public
59
+ ```
60
+
61
+ 8. Verify the published package:
62
+ ```bash
63
+ npm view @cryptolibertus/pi-peer version
64
+ npm view @cryptolibertus/pi-peer dist-tags --json
65
+ ```
66
+
67
+ ## Failure handling
68
+
69
+ - If `npm version` fails because the working tree is dirty, inspect `git status --short`; commit intended changes or revert accidental/generated files before retrying.
70
+ - If the tag already exists locally or remotely, compare the package version with `npm view`. Do not force-push tags. Pick the next valid version instead.
71
+ - If `npm publish` says the version already exists, do not retry the same version. Verify with `npm view`, then bump to the next patch version if the user still wants to publish.
72
+ - If tests fail, stop and fix the failure before bumping or publishing.
73
+
74
+ ## Final response
75
+
76
+ Report:
77
+
78
+ - Package name and version published
79
+ - Commit hash and tag pushed
80
+ - Verification commands with exit status
81
+ - npm package URL
82
+ - Any skipped step or blocker
@@ -251,11 +251,13 @@ function parsePeerGoalCommand(parsed, flags, positionals) {
251
251
  }
252
252
 
253
253
  function stringFlag(value, fallback) {
254
+ if (Array.isArray(value)) return stringFlag(value.at(-1), fallback);
254
255
  if (typeof value === "string" && value.trim()) return value.trim();
255
256
  return fallback;
256
257
  }
257
258
 
258
259
  function positiveIntegerFlag(value) {
260
+ if (Array.isArray(value)) return positiveIntegerFlag(value.at(-1));
259
261
  if (value === undefined || value === true) return undefined;
260
262
  const number = Number(value);
261
263
  return Number.isInteger(number) && number > 0 ? number : undefined;
@@ -288,7 +290,9 @@ function capabilitiesFromFlags(flags = {}) {
288
290
  }
289
291
 
290
292
  function listFlag(value) {
291
- if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim()).filter(Boolean))];
292
- if (typeof value !== "string" || !value.trim()) return [];
293
- return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
293
+ const values = Array.isArray(value) ? value : [value];
294
+ return [...new Set(values.flatMap((item) => {
295
+ if (typeof item !== "string") return [];
296
+ return item.split(",").map((part) => part.trim()).filter(Boolean);
297
+ }))];
294
298
  }
@@ -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
  }
@@ -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
- pi.sendMessage({
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;
@@ -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");
@@ -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
  ];
package/src/utils.mjs CHANGED
@@ -58,21 +58,22 @@ export function parseFlags(args) {
58
58
  const [rawKey, rawValue] = arg.slice(2).split(/=(.*)/s, 2);
59
59
  const key = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
60
60
  if (rawValue !== undefined) {
61
- flags[key] = rawValue;
61
+ appendFlagValue(flags, key, rawValue);
62
62
  continue;
63
63
  }
64
64
  const next = args[i + 1];
65
65
  if (next !== undefined && !next.startsWith("--")) {
66
- flags[key] = next;
66
+ appendFlagValue(flags, key, next);
67
67
  i += 1;
68
68
  } else {
69
- flags[key] = true;
69
+ appendFlagValue(flags, key, true);
70
70
  }
71
71
  }
72
72
  return { flags, positionals };
73
73
  }
74
74
 
75
75
  export function flagEnabled(value) {
76
+ if (Array.isArray(value)) return flagEnabled(value.at(-1));
76
77
  if (value === true) return true;
77
78
  if (typeof value === "number") return Number.isFinite(value) && value !== 0;
78
79
  if (typeof value === "string") {
@@ -81,3 +82,11 @@ export function flagEnabled(value) {
81
82
  }
82
83
  return false;
83
84
  }
85
+
86
+ function appendFlagValue(flags, key, value) {
87
+ if (Object.prototype.hasOwnProperty.call(flags, key)) {
88
+ flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
89
+ } else {
90
+ flags[key] = value;
91
+ }
92
+ }