@fitchmultz/pi-subagents 0.0.0-stage → 0.44.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/CHANGELOG.md +1654 -0
- package/LICENSE +21 -0
- package/README.md +1533 -2
- package/agents/context-builder.md +97 -0
- package/agents/debugger.md +51 -0
- package/agents/delegate.md +13 -0
- package/agents/fixer.md +37 -0
- package/agents/oracle.md +71 -0
- package/agents/planner.md +72 -0
- package/agents/researcher.md +51 -0
- package/agents/reviewer-claude.md +69 -0
- package/agents/reviewer-gpt.md +68 -0
- package/agents/reviewer-ponytail.md +82 -0
- package/agents/reviewer-security.md +81 -0
- package/agents/reviewer.md +69 -0
- package/agents/scout.md +66 -0
- package/agents/ui-designer.md +51 -0
- package/agents/watcher.md +26 -0
- package/agents/worker.md +46 -0
- package/agents/writer.md +36 -0
- package/dist/agents/agent-management.js +923 -0
- package/dist/agents/agent-scope.js +7 -0
- package/dist/agents/agent-selection.js +20 -0
- package/dist/agents/agent-serializer.js +100 -0
- package/dist/agents/agents.js +752 -0
- package/dist/agents/chain-serializer.js +367 -0
- package/dist/agents/frontmatter.js +21 -0
- package/dist/agents/identity.js +29 -0
- package/dist/agents/skills.js +570 -0
- package/dist/extension/build-info.js +1 -0
- package/dist/extension/compact-tools.js +112 -0
- package/dist/extension/config.js +22 -0
- package/dist/extension/control-notices.js +29 -0
- package/dist/extension/doctor.js +143 -0
- package/dist/extension/fanout-child.js +326 -0
- package/dist/extension/index.js +577 -0
- package/dist/extension/schemas.js +416 -0
- package/dist/extension/tool-input.js +20 -0
- package/dist/extension/tool-result.js +14 -0
- package/dist/history/canonical-result.js +81 -0
- package/dist/history/database.js +72 -0
- package/dist/history/index.js +199 -0
- package/dist/history/ingest.js +468 -0
- package/dist/history/queries.js +350 -0
- package/dist/history/store.js +216 -0
- package/dist/history/types.js +4 -0
- package/dist/history/worker.js +466 -0
- package/dist/intercom/intercom-bridge.js +112 -0
- package/dist/intercom/live-intercom.js +94 -0
- package/dist/intercom/result-intercom.js +373 -0
- package/dist/pi-intercom/agent-dir.js +10 -0
- package/dist/pi-intercom/bridge.js +750 -0
- package/dist/pi-intercom/broker/broker.js +426 -0
- package/dist/pi-intercom/broker/client.js +492 -0
- package/dist/pi-intercom/broker/framing.js +65 -0
- package/dist/pi-intercom/broker/paths.js +42 -0
- package/dist/pi-intercom/broker/pid.js +66 -0
- package/dist/pi-intercom/broker/spawn.js +221 -0
- package/dist/pi-intercom/config.js +102 -0
- package/dist/pi-intercom/index.js +2942 -0
- package/dist/pi-intercom/reply-tracker.js +182 -0
- package/dist/pi-intercom/session-targets.js +128 -0
- package/dist/pi-intercom/subagent-live-events.js +132 -0
- package/dist/pi-intercom/topics.js +157 -0
- package/dist/pi-intercom/types.js +163 -0
- package/dist/pi-intercom/ui/compose.js +269 -0
- package/dist/pi-intercom/ui/inline-message.js +80 -0
- package/dist/pi-intercom/ui/session-list.js +124 -0
- package/dist/runs/background/async-control.js +95 -0
- package/dist/runs/background/async-execution.js +676 -0
- package/dist/runs/background/async-job-tracker.js +383 -0
- package/dist/runs/background/async-result-file.js +58 -0
- package/dist/runs/background/async-resume.js +305 -0
- package/dist/runs/background/async-status.js +442 -0
- package/dist/runs/background/completion-dedupe.js +69 -0
- package/dist/runs/background/completion-delivery.js +245 -0
- package/dist/runs/background/notify.js +71 -0
- package/dist/runs/background/parallel-groups.js +55 -0
- package/dist/runs/background/result-watcher.js +426 -0
- package/dist/runs/background/run-id-resolver.js +64 -0
- package/dist/runs/background/run-status.js +382 -0
- package/dist/runs/background/stale-run-reconciler.js +335 -0
- package/dist/runs/background/subagent-runner-launcher.js +55 -0
- package/dist/runs/background/subagent-runner.js +1872 -0
- package/dist/runs/background/top-level-async.js +5 -0
- package/dist/runs/foreground/chain-clarify.js +1152 -0
- package/dist/runs/foreground/clarify-invocation.js +72 -0
- package/dist/runs/foreground/execution-input.js +423 -0
- package/dist/runs/foreground/foreground-control.js +817 -0
- package/dist/runs/foreground/question-control.js +88 -0
- package/dist/runs/foreground/run-async-path.js +205 -0
- package/dist/runs/foreground/subagent-executor.js +442 -0
- package/dist/runs/foreground/subagent-params.js +165 -0
- package/dist/runs/foreground/wait-run.js +141 -0
- package/dist/runs/shared/acceptance-contract.js +253 -0
- package/dist/runs/shared/acceptance-evaluation.js +256 -0
- package/dist/runs/shared/acceptance-finalization.js +280 -0
- package/dist/runs/shared/acceptance-reports.js +147 -0
- package/dist/runs/shared/acceptance.js +4 -0
- package/dist/runs/shared/chain-outputs.js +110 -0
- package/dist/runs/shared/child-attempt.js +693 -0
- package/dist/runs/shared/child-execution-cwd.js +96 -0
- package/dist/runs/shared/child-observations.js +114 -0
- package/dist/runs/shared/claude-code.js +199 -0
- package/dist/runs/shared/completion-guard.js +31 -0
- package/dist/runs/shared/dynamic-fanout.js +274 -0
- package/dist/runs/shared/history-index.js +72 -0
- package/dist/runs/shared/mcp-direct-tool-allowlist.js +334 -0
- package/dist/runs/shared/model-fallback.js +185 -0
- package/dist/runs/shared/mutating-tool-guard.js +170 -0
- package/dist/runs/shared/native-finalization.js +101 -0
- package/dist/runs/shared/native-usage.js +153 -0
- package/dist/runs/shared/nested-events.js +700 -0
- package/dist/runs/shared/nested-path.js +48 -0
- package/dist/runs/shared/nested-render.js +108 -0
- package/dist/runs/shared/parallel-utils.js +61 -0
- package/dist/runs/shared/parent-receipts.js +68 -0
- package/dist/runs/shared/parent-usage.js +121 -0
- package/dist/runs/shared/pi-args.js +310 -0
- package/dist/runs/shared/pi-spawn.js +52 -0
- package/dist/runs/shared/run-history-queries.js +45 -0
- package/dist/runs/shared/run-history.js +110 -0
- package/dist/runs/shared/run-id-ambiguity.js +4 -0
- package/dist/runs/shared/run-records.js +679 -0
- package/dist/runs/shared/session-cwd-preload.js +33 -0
- package/dist/runs/shared/single-output.js +222 -0
- package/dist/runs/shared/streaming-text.js +15 -0
- package/dist/runs/shared/structured-output.js +55 -0
- package/dist/runs/shared/subagent-control.js +179 -0
- package/dist/runs/shared/subagent-prompt-runtime.js +197 -0
- package/dist/runs/shared/subagent-tool-loop-guard.js +66 -0
- package/dist/runs/shared/supervisor-questions.js +350 -0
- package/dist/runs/shared/workflow-graph.js +214 -0
- package/dist/runs/shared/workflow-policy.js +84 -0
- package/dist/runs/shared/worktree.js +525 -0
- package/dist/shared/agent-context-policy.js +126 -0
- package/dist/shared/artifacts.js +96 -0
- package/dist/shared/atomic-json.js +13 -0
- package/dist/shared/cooperative.js +20 -0
- package/dist/shared/execution-cwd.js +14 -0
- package/dist/shared/file-coalescer.js +21 -0
- package/dist/shared/fork-context.js +49 -0
- package/dist/shared/formatters.js +85 -0
- package/dist/shared/journal-reader.js +407 -0
- package/dist/shared/lazy-tools.js +32 -0
- package/dist/shared/model-info.js +68 -0
- package/dist/shared/native-session.js +15 -0
- package/dist/shared/native-tui.js +18 -0
- package/dist/shared/native-typebox.js +20 -0
- package/dist/shared/post-exit-stdio-guard.js +197 -0
- package/dist/shared/prompt-sections.js +8 -0
- package/dist/shared/session-entries.js +52 -0
- package/dist/shared/session-identity.js +14 -0
- package/dist/shared/settings.js +254 -0
- package/dist/shared/status-format.js +111 -0
- package/dist/shared/temp-root.js +114 -0
- package/dist/shared/types.js +207 -0
- package/dist/shared/ui-mode.js +3 -0
- package/dist/shared/utils.js +334 -0
- package/dist/slash/prompt-template-bridge.js +298 -0
- package/dist/slash/slash-bridge.js +116 -0
- package/dist/slash/slash-commands.js +607 -0
- package/dist/slash/slash-live-state.js +246 -0
- package/dist/tui/action-hints.js +75 -0
- package/dist/tui/agent-history.js +197 -0
- package/dist/tui/agent-view.js +1656 -0
- package/dist/tui/render.js +1515 -0
- package/docs/intercom-bridge.md +212 -0
- package/docs/intercom.md +581 -0
- package/docs/performance.md +121 -0
- package/package.json +102 -4
- package/prompts/gather-context-and-clarify.md +14 -0
- package/prompts/parallel-cleanup.md +62 -0
- package/prompts/parallel-context-build.md +56 -0
- package/prompts/parallel-handoff-plan.md +62 -0
- package/prompts/parallel-research.md +51 -0
- package/prompts/parallel-review.md +59 -0
- package/prompts/review-loop.md +42 -0
- package/scripts/build.mjs +134 -0
- package/scripts/check-compat.mjs +48 -0
- package/scripts/compat-host.mjs +19 -0
- package/scripts/intercom-bridge-credentials.mjs +143 -0
- package/scripts/intercom-remote.mjs +103 -0
- package/scripts/linux-smoke.sh +51 -0
- package/scripts/local-install-smoke.mjs +66 -0
- package/scripts/native-package-smoke.mjs +82 -0
- package/scripts/package-smoke.mjs +209 -0
- package/scripts/prepare.mjs +67 -0
- package/scripts/real-pi-smoke.mjs +415 -0
- package/scripts/run-tests.mjs +149 -0
- package/skills/pi-intercom/SKILL.md +113 -0
- package/skills/pi-intercom/evals/evals.json +29 -0
- package/skills/pi-intercom/evals/trigger-evals.json +47 -0
- package/skills/pi-intercom/references/peer-sessions.md +69 -0
- package/skills/pi-subagents/SKILL.md +76 -0
- package/skills/pi-subagents/evals/evals.json +29 -0
- package/skills/pi-subagents/evals/trigger-evals.json +42 -0
- package/skills/pi-subagents/references/full-orchestration-guide.md +863 -0
- package/src/agents/agent-management.ts +813 -0
- package/src/agents/agent-scope.ts +7 -0
- package/src/agents/agent-selection.ts +23 -0
- package/src/agents/agent-serializer.ts +99 -0
- package/src/agents/agents.ts +916 -0
- package/src/agents/chain-serializer.ts +329 -0
- package/src/agents/frontmatter.ts +21 -0
- package/src/agents/identity.ts +30 -0
- package/src/agents/skills.ts +637 -0
- package/src/extension/build-info.ts +2 -0
- package/src/extension/compact-tools.ts +114 -0
- package/src/extension/config.ts +23 -0
- package/src/extension/control-notices.ts +47 -0
- package/src/extension/doctor.ts +171 -0
- package/src/extension/fanout-child.ts +312 -0
- package/src/extension/index.ts +649 -0
- package/src/extension/schemas.ts +444 -0
- package/src/extension/tool-input.ts +19 -0
- package/src/extension/tool-result.ts +16 -0
- package/src/history/canonical-result.ts +62 -0
- package/src/history/database.ts +59 -0
- package/src/history/index.ts +156 -0
- package/src/history/ingest.ts +349 -0
- package/src/history/queries.ts +241 -0
- package/src/history/store.ts +203 -0
- package/src/history/types.ts +166 -0
- package/src/history/worker.ts +321 -0
- package/src/intercom/intercom-bridge.ts +123 -0
- package/src/intercom/live-intercom.ts +104 -0
- package/src/intercom/result-intercom.ts +429 -0
- package/src/pi-intercom/agent-dir.ts +9 -0
- package/src/pi-intercom/bridge.ts +668 -0
- package/src/pi-intercom/broker/broker.ts +463 -0
- package/src/pi-intercom/broker/client.ts +558 -0
- package/src/pi-intercom/broker/framing.ts +77 -0
- package/src/pi-intercom/broker/paths.ts +47 -0
- package/src/pi-intercom/broker/pid.ts +59 -0
- package/src/pi-intercom/broker/spawn.ts +248 -0
- package/src/pi-intercom/config.ts +142 -0
- package/src/pi-intercom/index.ts +2929 -0
- package/src/pi-intercom/reply-tracker.ts +211 -0
- package/src/pi-intercom/session-targets.ts +172 -0
- package/src/pi-intercom/subagent-live-events.ts +133 -0
- package/src/pi-intercom/topics.ts +125 -0
- package/src/pi-intercom/types.ts +271 -0
- package/src/pi-intercom/ui/compose.ts +289 -0
- package/src/pi-intercom/ui/inline-message.ts +92 -0
- package/src/pi-intercom/ui/session-list.ts +139 -0
- package/src/runs/background/async-control.ts +89 -0
- package/src/runs/background/async-execution.ts +850 -0
- package/src/runs/background/async-job-tracker.ts +385 -0
- package/src/runs/background/async-result-file.ts +62 -0
- package/src/runs/background/async-resume.ts +333 -0
- package/src/runs/background/async-status.ts +481 -0
- package/src/runs/background/completion-dedupe.ts +79 -0
- package/src/runs/background/completion-delivery.ts +195 -0
- package/src/runs/background/notify.ts +123 -0
- package/src/runs/background/parallel-groups.ts +53 -0
- package/src/runs/background/result-watcher.ts +423 -0
- package/src/runs/background/run-id-resolver.ts +69 -0
- package/src/runs/background/run-status.ts +384 -0
- package/src/runs/background/stale-run-reconciler.ts +369 -0
- package/src/runs/background/subagent-runner-launcher.ts +56 -0
- package/src/runs/background/subagent-runner.ts +2053 -0
- package/src/runs/background/top-level-async.ts +13 -0
- package/src/runs/foreground/chain-clarify.ts +1340 -0
- package/src/runs/foreground/clarify-invocation.ts +77 -0
- package/src/runs/foreground/execution-input.ts +467 -0
- package/src/runs/foreground/foreground-control.ts +821 -0
- package/src/runs/foreground/question-control.ts +82 -0
- package/src/runs/foreground/run-async-path.ts +243 -0
- package/src/runs/foreground/subagent-executor.ts +501 -0
- package/src/runs/foreground/subagent-params.ts +320 -0
- package/src/runs/foreground/wait-run.ts +124 -0
- package/src/runs/shared/acceptance-contract.ts +257 -0
- package/src/runs/shared/acceptance-evaluation.ts +275 -0
- package/src/runs/shared/acceptance-finalization.ts +353 -0
- package/src/runs/shared/acceptance-reports.ts +152 -0
- package/src/runs/shared/acceptance.ts +33 -0
- package/src/runs/shared/chain-outputs.ts +114 -0
- package/src/runs/shared/child-attempt.ts +623 -0
- package/src/runs/shared/child-execution-cwd.ts +91 -0
- package/src/runs/shared/child-observations.ts +99 -0
- package/src/runs/shared/claude-code.ts +243 -0
- package/src/runs/shared/completion-guard.ts +35 -0
- package/src/runs/shared/dynamic-fanout.ts +295 -0
- package/src/runs/shared/history-index.ts +69 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +376 -0
- package/src/runs/shared/model-fallback.ts +219 -0
- package/src/runs/shared/mutating-tool-guard.ts +194 -0
- package/src/runs/shared/native-finalization.ts +128 -0
- package/src/runs/shared/native-usage.ts +113 -0
- package/src/runs/shared/nested-events.ts +752 -0
- package/src/runs/shared/nested-path.ts +52 -0
- package/src/runs/shared/nested-render.ts +119 -0
- package/src/runs/shared/parallel-utils.ts +153 -0
- package/src/runs/shared/parent-receipts.ts +59 -0
- package/src/runs/shared/parent-usage.ts +121 -0
- package/src/runs/shared/pi-args.ts +350 -0
- package/src/runs/shared/pi-spawn.ts +52 -0
- package/src/runs/shared/run-history-queries.ts +44 -0
- package/src/runs/shared/run-history.ts +100 -0
- package/src/runs/shared/run-id-ambiguity.ts +4 -0
- package/src/runs/shared/run-records.ts +607 -0
- package/src/runs/shared/session-cwd-preload.ts +27 -0
- package/src/runs/shared/single-output.ts +267 -0
- package/src/runs/shared/streaming-text.ts +18 -0
- package/src/runs/shared/structured-output.ts +68 -0
- package/src/runs/shared/subagent-control.ts +219 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +201 -0
- package/src/runs/shared/subagent-tool-loop-guard.ts +89 -0
- package/src/runs/shared/supervisor-questions.ts +415 -0
- package/src/runs/shared/workflow-graph.ts +220 -0
- package/src/runs/shared/workflow-policy.ts +125 -0
- package/src/runs/shared/worktree.ts +635 -0
- package/src/shared/agent-context-policy.ts +184 -0
- package/src/shared/artifacts.ts +95 -0
- package/src/shared/atomic-json.ts +16 -0
- package/src/shared/cooperative.ts +21 -0
- package/src/shared/execution-cwd.ts +14 -0
- package/src/shared/file-coalescer.ts +29 -0
- package/src/shared/fork-context.ts +68 -0
- package/src/shared/formatters.ts +84 -0
- package/src/shared/journal-reader.ts +333 -0
- package/src/shared/lazy-tools.ts +36 -0
- package/src/shared/model-info.ts +81 -0
- package/src/shared/native-session.ts +8 -0
- package/src/shared/native-tui.ts +10 -0
- package/src/shared/native-typebox.ts +14 -0
- package/src/shared/post-exit-stdio-guard.ts +204 -0
- package/src/shared/prompt-sections.ts +10 -0
- package/src/shared/session-entries.ts +49 -0
- package/src/shared/session-identity.ts +18 -0
- package/src/shared/settings.ts +384 -0
- package/src/shared/status-format.ts +123 -0
- package/src/shared/temp-root.ts +107 -0
- package/src/shared/types.ts +1260 -0
- package/src/shared/ui-mode.ts +5 -0
- package/src/shared/utils.ts +342 -0
- package/src/slash/prompt-template-bridge.ts +408 -0
- package/src/slash/slash-bridge.ts +178 -0
- package/src/slash/slash-commands.ts +617 -0
- package/src/slash/slash-live-state.ts +292 -0
- package/src/tui/action-hints.ts +69 -0
- package/src/tui/agent-history.ts +210 -0
- package/src/tui/agent-view.ts +1323 -0
- package/src/tui/render.ts +1550 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { formatSessionTarget, resolveSessionTarget, shortSessionId } from "./session-targets.js";
|
|
2
|
+
function resolveBySenderTarget(contexts, to) {
|
|
3
|
+
const sessions = contexts.map((context) => context.from);
|
|
4
|
+
const resolution = resolveSessionTarget(sessions, to);
|
|
5
|
+
if (resolution.status === "none" || resolution.status === "prefix_too_short") {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
const matchingIds = new Set(resolution.matches.map((session) => session.id));
|
|
9
|
+
return contexts.filter((context) => matchingIds.has(context.from.id));
|
|
10
|
+
}
|
|
11
|
+
function tooShortSenderTargetMessage(contexts, to) {
|
|
12
|
+
const resolution = resolveSessionTarget(contexts.map((context) => context.from), to);
|
|
13
|
+
if (resolution.status !== "prefix_too_short") {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const matchingIds = new Set(resolution.matches.map((session) => session.id));
|
|
17
|
+
const matches = contexts.filter((context) => matchingIds.has(context.from.id));
|
|
18
|
+
return `Pending ask target "${to}" is too short. Use one of: ${pendingSenderOptions(matches, contexts)}.`;
|
|
19
|
+
}
|
|
20
|
+
function pendingSenderOptions(contexts, allContexts = contexts) {
|
|
21
|
+
const allSenders = allContexts.map((context) => context.from);
|
|
22
|
+
return contexts
|
|
23
|
+
.map((context) => `${context.from.name || shortSessionId(context.from.id)}: to: ${JSON.stringify(formatSessionTarget(context.from, allSenders))} or replyTo: ${JSON.stringify(context.message.id)}`)
|
|
24
|
+
.join(", ");
|
|
25
|
+
}
|
|
26
|
+
export function isDurableSupervisorQuestion(message) {
|
|
27
|
+
return message.content.text.includes(`Question ID: ${message.id}`);
|
|
28
|
+
}
|
|
29
|
+
export class ReplyTracker {
|
|
30
|
+
pendingAsks = new Map();
|
|
31
|
+
pendingTurnContexts = [];
|
|
32
|
+
currentTurnContext = null;
|
|
33
|
+
activeAgentContext = null;
|
|
34
|
+
askTimeoutMs;
|
|
35
|
+
constructor(askTimeoutMs = 10 * 60 * 1000) {
|
|
36
|
+
this.askTimeoutMs = askTimeoutMs;
|
|
37
|
+
}
|
|
38
|
+
recordIncomingMessage(from, message, receivedAt = Date.now()) {
|
|
39
|
+
const context = { from, message, receivedAt };
|
|
40
|
+
if (message.expectsReply) {
|
|
41
|
+
this.pruneExpired(receivedAt);
|
|
42
|
+
this.pendingAsks.set(message.id, context);
|
|
43
|
+
}
|
|
44
|
+
return context;
|
|
45
|
+
}
|
|
46
|
+
queueTurnContext(context) {
|
|
47
|
+
if (!context.message.expectsReply)
|
|
48
|
+
return;
|
|
49
|
+
if (this.hasTurnContext(context.message.id))
|
|
50
|
+
return;
|
|
51
|
+
this.pendingTurnContexts.push(context);
|
|
52
|
+
}
|
|
53
|
+
beginTurn(now = Date.now()) {
|
|
54
|
+
this.pruneExpired(now);
|
|
55
|
+
this.currentTurnContext = this.pendingTurnContexts.shift() ?? null;
|
|
56
|
+
if (this.currentTurnContext) {
|
|
57
|
+
this.activeAgentContext = this.currentTurnContext;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
currentTurn() {
|
|
61
|
+
return this.currentTurnContext ?? this.activeAgentContext;
|
|
62
|
+
}
|
|
63
|
+
endTurn() {
|
|
64
|
+
this.currentTurnContext = null;
|
|
65
|
+
}
|
|
66
|
+
endAgent() {
|
|
67
|
+
this.currentTurnContext = null;
|
|
68
|
+
this.activeAgentContext = null;
|
|
69
|
+
}
|
|
70
|
+
reset() {
|
|
71
|
+
this.pendingAsks.clear();
|
|
72
|
+
this.pendingTurnContexts.length = 0;
|
|
73
|
+
this.currentTurnContext = null;
|
|
74
|
+
this.activeAgentContext = null;
|
|
75
|
+
}
|
|
76
|
+
expireSender(sessionId) {
|
|
77
|
+
const expires = (context) => context.from.id === sessionId && !isDurableSupervisorQuestion(context.message);
|
|
78
|
+
let expired = 0;
|
|
79
|
+
for (const [messageId, context] of this.pendingAsks) {
|
|
80
|
+
if (expires(context)) {
|
|
81
|
+
this.pendingAsks.delete(messageId);
|
|
82
|
+
expired += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const beforeQueued = this.pendingTurnContexts.length;
|
|
86
|
+
for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
|
|
87
|
+
if (expires(this.pendingTurnContexts[index])) {
|
|
88
|
+
this.pendingTurnContexts.splice(index, 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
expired += beforeQueued - this.pendingTurnContexts.length;
|
|
92
|
+
if (this.currentTurnContext && expires(this.currentTurnContext)) {
|
|
93
|
+
this.currentTurnContext = null;
|
|
94
|
+
expired += 1;
|
|
95
|
+
}
|
|
96
|
+
if (this.activeAgentContext && expires(this.activeAgentContext)) {
|
|
97
|
+
this.activeAgentContext = null;
|
|
98
|
+
expired += 1;
|
|
99
|
+
}
|
|
100
|
+
return expired;
|
|
101
|
+
}
|
|
102
|
+
resolveReplyTarget(options, now = Date.now()) {
|
|
103
|
+
this.pruneExpired(now);
|
|
104
|
+
const pending = Array.from(this.pendingAsks.values());
|
|
105
|
+
const contexts = this.currentTurnContext
|
|
106
|
+
? [this.currentTurnContext, ...pending.filter((context) => context.message.id !== this.currentTurnContext?.message.id)]
|
|
107
|
+
: pending;
|
|
108
|
+
if (options.replyTo) {
|
|
109
|
+
const target = contexts.find((context) => context.message.id === options.replyTo);
|
|
110
|
+
if (!target) {
|
|
111
|
+
throw new Error(`No pending ask with replyTo "${options.replyTo}"`);
|
|
112
|
+
}
|
|
113
|
+
if (options.to) {
|
|
114
|
+
const tooShortMessage = tooShortSenderTargetMessage(contexts, options.to);
|
|
115
|
+
if (tooShortMessage) {
|
|
116
|
+
throw new Error(tooShortMessage);
|
|
117
|
+
}
|
|
118
|
+
const senderMatches = resolveBySenderTarget(contexts, options.to);
|
|
119
|
+
if (!senderMatches.some((context) => context.message.id === target.message.id)) {
|
|
120
|
+
throw new Error(`Pending ask "${options.replyTo}" is not from "${options.to}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return target;
|
|
124
|
+
}
|
|
125
|
+
if (options.to) {
|
|
126
|
+
const tooShortMessage = tooShortSenderTargetMessage(contexts, options.to);
|
|
127
|
+
if (tooShortMessage) {
|
|
128
|
+
throw new Error(tooShortMessage);
|
|
129
|
+
}
|
|
130
|
+
const matches = resolveBySenderTarget(contexts, options.to);
|
|
131
|
+
if (matches.length === 1) {
|
|
132
|
+
return matches[0];
|
|
133
|
+
}
|
|
134
|
+
if (matches.length > 1) {
|
|
135
|
+
throw new Error(`Multiple pending asks from \"${options.to}\" — use one of: ${pendingSenderOptions(matches, contexts)}.`);
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`No pending ask from \"${options.to}\"`);
|
|
138
|
+
}
|
|
139
|
+
if (this.currentTurnContext) {
|
|
140
|
+
return this.currentTurnContext;
|
|
141
|
+
}
|
|
142
|
+
if (pending.length === 1) {
|
|
143
|
+
return pending[0];
|
|
144
|
+
}
|
|
145
|
+
if (pending.length === 0) {
|
|
146
|
+
throw new Error("No active intercom context to reply to");
|
|
147
|
+
}
|
|
148
|
+
throw new Error("Multiple pending asks — specify `to`");
|
|
149
|
+
}
|
|
150
|
+
markReplied(replyTo) {
|
|
151
|
+
this.removeContext(replyTo);
|
|
152
|
+
}
|
|
153
|
+
get hasReplyContext() {
|
|
154
|
+
return this.pendingAsks.size > 0 || this.pendingTurnContexts.length > 0 || this.currentTurnContext !== null || this.activeAgentContext !== null;
|
|
155
|
+
}
|
|
156
|
+
listPending(now = Date.now()) {
|
|
157
|
+
this.pruneExpired(now);
|
|
158
|
+
return Array.from(this.pendingAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
|
|
159
|
+
}
|
|
160
|
+
hasTurnContext(messageId) {
|
|
161
|
+
return this.pendingTurnContexts.some((context) => context.message.id === messageId)
|
|
162
|
+
|| this.currentTurnContext?.message.id === messageId
|
|
163
|
+
|| this.activeAgentContext?.message.id === messageId;
|
|
164
|
+
}
|
|
165
|
+
removeContext(messageId) {
|
|
166
|
+
this.pendingAsks.delete(messageId);
|
|
167
|
+
for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
|
|
168
|
+
if (this.pendingTurnContexts[index]?.message.id === messageId)
|
|
169
|
+
this.pendingTurnContexts.splice(index, 1);
|
|
170
|
+
}
|
|
171
|
+
if (this.currentTurnContext?.message.id === messageId)
|
|
172
|
+
this.currentTurnContext = null;
|
|
173
|
+
if (this.activeAgentContext?.message.id === messageId)
|
|
174
|
+
this.activeAgentContext = null;
|
|
175
|
+
}
|
|
176
|
+
pruneExpired(now) {
|
|
177
|
+
for (const [messageId, context] of this.pendingAsks) {
|
|
178
|
+
if (!isDurableSupervisorQuestion(context.message) && now - context.receivedAt > this.askTimeoutMs)
|
|
179
|
+
this.removeContext(messageId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const MIN_SESSION_TARGET_PREFIX_LENGTH = 8;
|
|
5
|
+
export function shortSessionId(sessionId) {
|
|
6
|
+
return sessionId.slice(0, MIN_SESSION_TARGET_PREFIX_LENGTH);
|
|
7
|
+
}
|
|
8
|
+
function normalizedPath(value) {
|
|
9
|
+
return path.resolve(value);
|
|
10
|
+
}
|
|
11
|
+
function resolveGitCommonDirectory(cwd) {
|
|
12
|
+
const env = { ...process.env };
|
|
13
|
+
delete env.GIT_DIR;
|
|
14
|
+
delete env.GIT_COMMON_DIR;
|
|
15
|
+
delete env.GIT_WORK_TREE;
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
execFile("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
18
|
+
cwd: path.dirname(process.execPath),
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
env,
|
|
21
|
+
maxBuffer: 4096,
|
|
22
|
+
timeout: 500,
|
|
23
|
+
}, (error, stdout) => {
|
|
24
|
+
const gitDirectory = error ? "" : stdout.trim();
|
|
25
|
+
resolve(gitDirectory && !/[\0\r\n]/.test(gitDirectory)
|
|
26
|
+
? normalizedPath(path.resolve(cwd, gitDirectory))
|
|
27
|
+
: undefined);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function resolveSessionProjectId(cwd) {
|
|
32
|
+
const gitDirectory = await resolveGitCommonDirectory(cwd);
|
|
33
|
+
const identity = gitDirectory ? `git:${gitDirectory}` : `cwd:${normalizedPath(cwd)}`;
|
|
34
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
function normalizedNames(sessions) {
|
|
37
|
+
return new Set(sessions
|
|
38
|
+
.map((session) => session.name?.trim().toLowerCase())
|
|
39
|
+
.filter((name) => Boolean(name)));
|
|
40
|
+
}
|
|
41
|
+
export function formatSessionTarget(session, allSessions = [session]) {
|
|
42
|
+
const ids = allSessions.map((candidate) => candidate.id.toLowerCase());
|
|
43
|
+
const names = normalizedNames(allSessions);
|
|
44
|
+
const id = session.id.toLowerCase();
|
|
45
|
+
for (let length = MIN_SESSION_TARGET_PREFIX_LENGTH; length < session.id.length; length += 1) {
|
|
46
|
+
const prefix = id.slice(0, length);
|
|
47
|
+
const uniqueIdPrefix = ids.filter((candidateId) => candidateId.startsWith(prefix)).length === 1;
|
|
48
|
+
if (uniqueIdPrefix && !names.has(prefix)) {
|
|
49
|
+
return session.id.slice(0, length);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return session.id;
|
|
53
|
+
}
|
|
54
|
+
export function formatTargetOptions(sessions, allSessions = sessions) {
|
|
55
|
+
return sessions
|
|
56
|
+
.map((session) => `${session.name || shortSessionId(session.id)} → ${formatSessionTarget(session, allSessions)}`)
|
|
57
|
+
.join(", ");
|
|
58
|
+
}
|
|
59
|
+
export function targetDisplayName(session, allSessions = [session]) {
|
|
60
|
+
if (!session.name?.trim()) {
|
|
61
|
+
return session.id;
|
|
62
|
+
}
|
|
63
|
+
const lowerName = session.name.trim().toLowerCase();
|
|
64
|
+
const duplicateName = allSessions.some((candidate) => candidate.id !== session.id && candidate.name?.trim().toLowerCase() === lowerName);
|
|
65
|
+
const nameConflictsWithOtherIdPrefix = allSessions.some((candidate) => candidate.id !== session.id && candidate.id.toLowerCase().startsWith(lowerName));
|
|
66
|
+
return duplicateName || nameConflictsWithOtherIdPrefix
|
|
67
|
+
? `${session.name} (${formatSessionTarget(session, allSessions)})`
|
|
68
|
+
: session.name;
|
|
69
|
+
}
|
|
70
|
+
// The hint is deliberately a constant: it is appended to the system prompt,
|
|
71
|
+
// which is the very start of the provider prompt-cache prefix. Any per-turn
|
|
72
|
+
// variation there (live peer counts, checkout counts) invalidates the entire
|
|
73
|
+
// cached context for every turn in which fleet membership changed, which is
|
|
74
|
+
// expensive on large sessions. Live details belong behind intercom list.
|
|
75
|
+
export const PEER_AWARENESS_HINT = `Other Pi sessions may be connected to this project. Use load_intercom({}) if needed, then intercom({ action: "list" }) before changing shared state or coordinating known overlapping work. Routine standalone read-only tasks do not need a peer check. Coordinate only when work overlaps; use subagent controls for managed child runs.`;
|
|
76
|
+
export function filterProjectSessions(sessions, currentSessionId) {
|
|
77
|
+
const current = sessions.find((session) => session.id === currentSessionId);
|
|
78
|
+
if (!current)
|
|
79
|
+
return [];
|
|
80
|
+
return sessions.filter((session) => session.id === currentSessionId
|
|
81
|
+
|| session.cwd === current.cwd
|
|
82
|
+
|| Boolean(current.projectId && session.projectId && session.projectId === current.projectId));
|
|
83
|
+
}
|
|
84
|
+
export function formatPeerAwarenessHint(sessions, currentSessionId) {
|
|
85
|
+
return filterProjectSessions(sessions, currentSessionId).some((session) => session.id !== currentSessionId)
|
|
86
|
+
? PEER_AWARENESS_HINT
|
|
87
|
+
: undefined;
|
|
88
|
+
}
|
|
89
|
+
export function resolveSessionTarget(sessions, rawTarget) {
|
|
90
|
+
const target = rawTarget.trim();
|
|
91
|
+
const lowerTarget = target.toLowerCase();
|
|
92
|
+
const exactIdMatches = sessions.filter((session) => session.id.toLowerCase() === lowerTarget);
|
|
93
|
+
if (exactIdMatches.length === 1) {
|
|
94
|
+
return { status: "found", target: exactIdMatches[0], matches: exactIdMatches };
|
|
95
|
+
}
|
|
96
|
+
if (exactIdMatches.length > 1) {
|
|
97
|
+
return { status: "ambiguous", matches: exactIdMatches };
|
|
98
|
+
}
|
|
99
|
+
const nameMatches = sessions.filter((session) => session.name?.trim().toLowerCase() === lowerTarget);
|
|
100
|
+
const allPrefixMatches = sessions.filter((session) => session.id.toLowerCase().startsWith(lowerTarget));
|
|
101
|
+
const prefixMatches = target.length >= MIN_SESSION_TARGET_PREFIX_LENGTH ? allPrefixMatches : [];
|
|
102
|
+
if (target.length > 0 && target.length < MIN_SESSION_TARGET_PREFIX_LENGTH && allPrefixMatches.length > 0) {
|
|
103
|
+
if (nameMatches.length > 0) {
|
|
104
|
+
const matchesById = new Map();
|
|
105
|
+
for (const session of [...nameMatches, ...allPrefixMatches]) {
|
|
106
|
+
matchesById.set(session.id, session);
|
|
107
|
+
}
|
|
108
|
+
const matches = Array.from(matchesById.values());
|
|
109
|
+
if (matches.length === 1) {
|
|
110
|
+
return { status: "found", target: matches[0], matches };
|
|
111
|
+
}
|
|
112
|
+
return { status: "ambiguous", matches };
|
|
113
|
+
}
|
|
114
|
+
return { status: "prefix_too_short", matches: allPrefixMatches, minLength: MIN_SESSION_TARGET_PREFIX_LENGTH };
|
|
115
|
+
}
|
|
116
|
+
const matchesById = new Map();
|
|
117
|
+
for (const session of [...nameMatches, ...prefixMatches]) {
|
|
118
|
+
matchesById.set(session.id, session);
|
|
119
|
+
}
|
|
120
|
+
const matches = Array.from(matchesById.values());
|
|
121
|
+
if (matches.length === 1) {
|
|
122
|
+
return { status: "found", target: matches[0], matches };
|
|
123
|
+
}
|
|
124
|
+
if (matches.length > 1) {
|
|
125
|
+
return { status: "ambiguous", matches };
|
|
126
|
+
}
|
|
127
|
+
return { status: "none", matches: [] };
|
|
128
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { formatSessionTarget, resolveSessionTarget as resolveSessionTargetValue } from "./session-targets.js";
|
|
2
|
+
import { isAttachment, isHumanMessageOrigin } from "./types.js";
|
|
3
|
+
const SUBAGENT_LIVE_INTERCOM_EVENT = "subagent:live-intercom";
|
|
4
|
+
const SUBAGENT_LIVE_INTERCOM_DELIVERY_EVENT = "subagent:live-intercom-delivery";
|
|
5
|
+
const SUBAGENT_INTERCOM_HEALTH_REQUEST_EVENT = "subagent:intercom-health-request";
|
|
6
|
+
const SUBAGENT_INTERCOM_HEALTH_RESPONSE_EVENT = "subagent:intercom-health-response";
|
|
7
|
+
function getErrorMessage(error) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error);
|
|
9
|
+
}
|
|
10
|
+
function emitLiveDelivery(events, requestId, delivered, reason, receipt) {
|
|
11
|
+
if (!requestId)
|
|
12
|
+
return;
|
|
13
|
+
events.emit(SUBAGENT_LIVE_INTERCOM_DELIVERY_EVENT, {
|
|
14
|
+
requestId,
|
|
15
|
+
delivered,
|
|
16
|
+
...(receipt ? { messageId: receipt.id, accepted: receipt.accepted, queued: receipt.queued } : {}),
|
|
17
|
+
...(reason ? { reason } : {}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function parseLiveMessagePayload(payload) {
|
|
21
|
+
if (!payload || typeof payload !== "object")
|
|
22
|
+
return undefined;
|
|
23
|
+
const parsed = payload;
|
|
24
|
+
if (typeof parsed.requestId !== "string" || typeof parsed.to !== "string" || typeof parsed.message !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
return {
|
|
27
|
+
requestId: parsed.requestId,
|
|
28
|
+
to: parsed.to,
|
|
29
|
+
message: parsed.message,
|
|
30
|
+
delivery: parsed.delivery === "queue" ? "queue" : "steer",
|
|
31
|
+
...(typeof parsed.messageId === "string" ? { messageId: parsed.messageId } : {}),
|
|
32
|
+
...(isHumanMessageOrigin(parsed.human) ? { human: parsed.human } : {}),
|
|
33
|
+
...(Array.isArray(parsed.attachments) && parsed.attachments.every(isAttachment) ? { attachments: parsed.attachments } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function relayLiveSubagentMessage(payload, deps) {
|
|
37
|
+
const parsed = parseLiveMessagePayload(payload);
|
|
38
|
+
if (!parsed)
|
|
39
|
+
return;
|
|
40
|
+
const isLive = deps.getLivenessCheck();
|
|
41
|
+
if (!isLive())
|
|
42
|
+
return;
|
|
43
|
+
let activeClient;
|
|
44
|
+
let target;
|
|
45
|
+
try {
|
|
46
|
+
activeClient = await deps.ensureConnected();
|
|
47
|
+
target = await deps.resolveSessionTarget(activeClient, parsed.to) ?? parsed.to;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (isLive())
|
|
51
|
+
emitLiveDelivery(deps.events, parsed.requestId, false, getErrorMessage(error));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!isLive())
|
|
55
|
+
return;
|
|
56
|
+
if (deps.currentSessionTargetMatches(parsed.to, target, activeClient)) {
|
|
57
|
+
emitLiveDelivery(deps.events, parsed.requestId, false, "Cannot message the current session");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const result = await activeClient.send(target, { text: parsed.message, delivery: parsed.delivery, messageId: parsed.messageId, human: parsed.human, attachments: parsed.attachments });
|
|
62
|
+
if (isLive())
|
|
63
|
+
emitLiveDelivery(deps.events, parsed.requestId, result.delivered, result.reason, result);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (isLive())
|
|
67
|
+
emitLiveDelivery(deps.events, parsed.requestId, false, getErrorMessage(error));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function parseHealthPayload(payload) {
|
|
71
|
+
if (!payload || typeof payload !== "object")
|
|
72
|
+
return undefined;
|
|
73
|
+
const parsed = payload;
|
|
74
|
+
if (typeof parsed.requestId !== "string" || !Array.isArray(parsed.targets))
|
|
75
|
+
return undefined;
|
|
76
|
+
const targets = parsed.targets.filter((target) => typeof target === "string" && target.trim().length > 0);
|
|
77
|
+
return { requestId: parsed.requestId, targets };
|
|
78
|
+
}
|
|
79
|
+
async function answerLiveIntercomHealth(payload, deps) {
|
|
80
|
+
const parsed = parseHealthPayload(payload);
|
|
81
|
+
if (!parsed)
|
|
82
|
+
return;
|
|
83
|
+
const isLive = deps.getLivenessCheck();
|
|
84
|
+
const connectionSnapshot = () => {
|
|
85
|
+
const { client, connecting, started } = deps.getConnection();
|
|
86
|
+
return { status: !started ? "unknown" : connecting ? "connecting" : client?.isConnected() ? "unknown" : "disconnected", ...(client?.sessionId ? { sessionId: client.sessionId } : {}) };
|
|
87
|
+
};
|
|
88
|
+
const respond = (health, connection) => {
|
|
89
|
+
if (isLive())
|
|
90
|
+
deps.events.emit(SUBAGENT_INTERCOM_HEALTH_RESPONSE_EVENT, { requestId: parsed.requestId, health, connection });
|
|
91
|
+
};
|
|
92
|
+
if (!isLive())
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
// An empty target list is a read-only check of this bridge, not a reconnect request.
|
|
96
|
+
const activeClient = parsed.targets.length ? await deps.ensureConnected() : deps.getConnection().client;
|
|
97
|
+
if (!activeClient?.isConnected()) {
|
|
98
|
+
respond([], connectionSnapshot());
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const sessions = await activeClient.listSessions();
|
|
102
|
+
const health = parsed.targets.map((target) => {
|
|
103
|
+
const resolution = resolveSessionTargetValue(sessions, target);
|
|
104
|
+
if (resolution.status !== "found")
|
|
105
|
+
return { target, status: resolution.status };
|
|
106
|
+
const session = resolution.target;
|
|
107
|
+
return {
|
|
108
|
+
target,
|
|
109
|
+
status: "registered",
|
|
110
|
+
resolvedTarget: formatSessionTarget(session, sessions),
|
|
111
|
+
sessionId: session.id,
|
|
112
|
+
...(session.name ? { sessionName: session.name } : {}),
|
|
113
|
+
...(session.status ? { sessionStatus: session.status } : {}),
|
|
114
|
+
...(session.acceptsAsks !== undefined ? { acceptsAsks: session.acceptsAsks } : {}),
|
|
115
|
+
...(session.pendingAsks !== undefined ? { pendingAsks: session.pendingAsks } : {}),
|
|
116
|
+
...(session.lastSeen !== undefined ? { lastSeen: session.lastSeen } : {}),
|
|
117
|
+
...(session.lastIntercomActivity !== undefined ? { lastIntercomActivity: session.lastIntercomActivity } : {}),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
const registered = activeClient.isConnected() && sessions.some((session) => session.id === activeClient.sessionId);
|
|
121
|
+
respond(health, registered ? { status: "connected", sessionId: activeClient.sessionId } : { ...connectionSnapshot(), reason: "Current broker registration was not confirmed." });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
respond(parsed.targets.map((target) => ({ target, status: "missing" })), { ...connectionSnapshot(), reason: getErrorMessage(error) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function registerSubagentLiveEventHandlers(deps) {
|
|
128
|
+
return [
|
|
129
|
+
deps.events.on(SUBAGENT_LIVE_INTERCOM_EVENT, (payload) => relayLiveSubagentMessage(payload, deps)),
|
|
130
|
+
deps.events.on(SUBAGENT_INTERCOM_HEALTH_REQUEST_EVENT, (payload) => answerLiveIntercomHealth(payload, deps)),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Container, ScrollView, Text, matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
import { actionHints } from "../tui/action-hints.js";
|
|
3
|
+
import { isTopicSubscription, isTopicUpdate } from "./types.js";
|
|
4
|
+
const TOPIC_ENTRY = "intercom-topic";
|
|
5
|
+
/** Latest inspectable state plus native audit entries; routine updates never enter model context. */
|
|
6
|
+
export class IntercomTopics {
|
|
7
|
+
pi;
|
|
8
|
+
getContext;
|
|
9
|
+
records = new Map();
|
|
10
|
+
disconnectedOwners = new Set();
|
|
11
|
+
render;
|
|
12
|
+
hydrate = new Set();
|
|
13
|
+
subscriptions = new Map();
|
|
14
|
+
published = new Map();
|
|
15
|
+
constructor(pi, getContext) { this.pi = pi; this.getContext = getContext; }
|
|
16
|
+
save(data) {
|
|
17
|
+
const ctx = this.getContext();
|
|
18
|
+
if (ctx)
|
|
19
|
+
this.pi.appendEntry(TOPIC_ENTRY, { sessionId: ctx.sessionManager.getSessionId(), ...data });
|
|
20
|
+
}
|
|
21
|
+
start(ctx) {
|
|
22
|
+
this.render = undefined;
|
|
23
|
+
this.records.clear();
|
|
24
|
+
this.disconnectedOwners.clear();
|
|
25
|
+
this.hydrate.clear();
|
|
26
|
+
this.subscriptions.clear();
|
|
27
|
+
this.published.clear();
|
|
28
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
29
|
+
if (entry.type !== "custom" || entry.customType !== TOPIC_ENTRY)
|
|
30
|
+
continue;
|
|
31
|
+
const data = entry.data;
|
|
32
|
+
if (data?.sessionId !== ctx.sessionManager.getSessionId())
|
|
33
|
+
continue;
|
|
34
|
+
if (Array.isArray(data.subscriptions) && data.subscriptions.every(isTopicSubscription)) {
|
|
35
|
+
this.subscriptions.clear();
|
|
36
|
+
for (const subscription of data.subscriptions)
|
|
37
|
+
this.subscriptions.set(subscription.topic, subscription);
|
|
38
|
+
}
|
|
39
|
+
if (isTopicUpdate(data.published))
|
|
40
|
+
this.published.set(data.published.topic, data.published);
|
|
41
|
+
if (data.record && isTopicUpdate(data.record.update))
|
|
42
|
+
this.records.set(`${data.record.from.id}:${data.record.update.topic}`, { ...data.record, connected: false });
|
|
43
|
+
}
|
|
44
|
+
for (const topic of this.subscriptions.keys())
|
|
45
|
+
this.hydrate.add(topic);
|
|
46
|
+
}
|
|
47
|
+
subscribe(topic, awaitRelease) {
|
|
48
|
+
this.subscriptions.set(topic, { topic, ...(awaitRelease ? { awaitRelease } : {}) });
|
|
49
|
+
this.hydrate.add(topic);
|
|
50
|
+
this.save({ subscriptions: [...this.subscriptions.values()] });
|
|
51
|
+
}
|
|
52
|
+
unsubscribe(topic) { this.subscriptions.delete(topic); this.save({ subscriptions: [...this.subscriptions.values()] }); }
|
|
53
|
+
publish(update, from) { this.published.set(update.topic, update); this.save({ published: update }); this.record(from, update); }
|
|
54
|
+
presence() { return { subscriptions: [...this.subscriptions.values()], topics: [...this.published.values()] }; }
|
|
55
|
+
record(from, update, connected = true) {
|
|
56
|
+
const key = `${from.id}:${update.topic}`, previous = this.records.get(key);
|
|
57
|
+
if (previous && previous.update.revision >= update.revision)
|
|
58
|
+
return false;
|
|
59
|
+
const record = { from: { id: from.id, name: from.name }, update, connected: connected && !this.disconnectedOwners.has(from.id), notifiedRevision: previous?.notifiedRevision };
|
|
60
|
+
this.records.set(key, record);
|
|
61
|
+
this.save({ record });
|
|
62
|
+
this.render?.();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/** True means handled as quiet state (or unsubscribed/obsolete), not a conversation message. */
|
|
66
|
+
receive(from, message) {
|
|
67
|
+
const update = message.topic;
|
|
68
|
+
if (!update)
|
|
69
|
+
return false;
|
|
70
|
+
const subscription = this.subscriptions.get(update.topic);
|
|
71
|
+
if (!subscription)
|
|
72
|
+
return true;
|
|
73
|
+
const key = `${from.id}:${update.topic}`;
|
|
74
|
+
if ((this.records.get(key)?.update.revision ?? 0) > update.revision)
|
|
75
|
+
return true;
|
|
76
|
+
this.record(from, update);
|
|
77
|
+
if (update.event === "update" || update.event === "release" && !subscription.awaitRelease)
|
|
78
|
+
return true;
|
|
79
|
+
const record = this.records.get(key);
|
|
80
|
+
if ((record.notifiedRevision ?? 0) >= update.revision)
|
|
81
|
+
return true;
|
|
82
|
+
record.notifiedRevision = update.revision;
|
|
83
|
+
this.save({ record });
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
refresh(sessions) {
|
|
87
|
+
for (const session of sessions)
|
|
88
|
+
this.disconnectedOwners.delete(session.id);
|
|
89
|
+
for (const record of this.records.values())
|
|
90
|
+
record.connected = sessions.some((session) => session.id === record.from.id);
|
|
91
|
+
for (const session of sessions)
|
|
92
|
+
for (const update of session.topics ?? []) {
|
|
93
|
+
if (this.subscriptions.has(update.topic)) {
|
|
94
|
+
this.record(session, update);
|
|
95
|
+
const record = this.records.get(`${session.id}:${update.topic}`);
|
|
96
|
+
if (this.hydrate.has(update.topic) && (record.notifiedRevision ?? 0) < update.revision) {
|
|
97
|
+
record.notifiedRevision = update.revision;
|
|
98
|
+
this.save({ record });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.hydrate.clear();
|
|
103
|
+
}
|
|
104
|
+
disconnected(id) {
|
|
105
|
+
if (id)
|
|
106
|
+
this.disconnectedOwners.add(id);
|
|
107
|
+
for (const record of this.records.values())
|
|
108
|
+
if (!id || record.from.id === id) {
|
|
109
|
+
record.connected = false;
|
|
110
|
+
this.disconnectedOwners.add(record.from.id);
|
|
111
|
+
}
|
|
112
|
+
this.render?.();
|
|
113
|
+
}
|
|
114
|
+
inspect(topic) {
|
|
115
|
+
const subscriptions = [...this.subscriptions.values()].filter((item) => !topic || item.topic === topic);
|
|
116
|
+
const records = [...this.records.values()].filter((record) => !topic || record.update.topic === topic).sort((a, b) => b.update.updatedAt - a.update.updatedAt);
|
|
117
|
+
return ["Intercom topics · latest self-contained state (not a work queue or exclusive lock)",
|
|
118
|
+
`Subscriptions: ${subscriptions.map((item) => `${item.topic}${item.awaitRelease ? " (awaiting release)" : ""}`).join(", ") || "none"}`,
|
|
119
|
+
...records.map(({ from, update, connected }) => `\n${update.topic} · ${from.name ?? from.id} · ${connected ? "connected" : "disconnected / unavailable"}\n${update.resource ? `Resource: ${update.resource} · declared ${update.ownership ?? "state unknown"}${!connected && update.ownership !== "released" ? "; disconnect is not release" : ""}\n` : ""}${new Date(update.updatedAt).toISOString()} · ${update.event}\n${update.text}`),
|
|
120
|
+
...(!records.length ? ["No current records. Subscribe to an exact topic or publish a self-contained update."] : []),
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
async open(ctx, notice) {
|
|
124
|
+
await ctx.ui.custom((tui, _theme, _keys, done) => {
|
|
125
|
+
const text = new Text("", 0, 0);
|
|
126
|
+
const scroll = new ScrollView(text, { follow: "none", scrollbar: "hidden" });
|
|
127
|
+
const render = () => tui.requestRender();
|
|
128
|
+
this.render = render;
|
|
129
|
+
let closed = false;
|
|
130
|
+
const act = (delta) => { if (closed)
|
|
131
|
+
return; if (delta === undefined)
|
|
132
|
+
done(undefined);
|
|
133
|
+
else
|
|
134
|
+
scroll.scrollBy(delta); tui.requestRender(); };
|
|
135
|
+
const view = new Container();
|
|
136
|
+
view.addChild({
|
|
137
|
+
invalidate() { scroll.invalidate(); },
|
|
138
|
+
render: (width) => { text.setText([notice, this.inspect()].filter(Boolean).join("\n\n")); const lines = scroll.render(width); const height = Math.max(1, tui.terminal.rows - 2); scroll.updateLayout(lines.length, height, render); return lines.slice(scroll.scrollTop, scroll.scrollTop + height); },
|
|
139
|
+
});
|
|
140
|
+
view.addChild(actionHints([
|
|
141
|
+
{ text: "↑", run: () => act(-1) }, "/", { text: "↓", run: () => act(1) }, " ", { text: "PgUp", run: () => act(-scroll.viewportHeight) }, "/",
|
|
142
|
+
{ text: "PgDn Read", run: () => act(scroll.viewportHeight) }, " · ", { text: "Esc Back", run: () => act() },
|
|
143
|
+
], undefined, "..."));
|
|
144
|
+
return {
|
|
145
|
+
invalidate() { view.invalidate(); },
|
|
146
|
+
dispose: () => { closed = true; if (this.render === render)
|
|
147
|
+
this.render = undefined; },
|
|
148
|
+
render: (width) => view.render(width),
|
|
149
|
+
handleInput(data) { act(matchesKey(data, "escape") ? undefined : matchesKey(data, "pageUp") ? -scroll.viewportHeight : matchesKey(data, "pageDown") ? scroll.viewportHeight : matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0); },
|
|
150
|
+
handleMouse(event) { if (event.type === "wheel") {
|
|
151
|
+
act(event.wheelDelta ?? 0);
|
|
152
|
+
return { handled: true };
|
|
153
|
+
} return view.handleMouse(event); },
|
|
154
|
+
};
|
|
155
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left" } });
|
|
156
|
+
}
|
|
157
|
+
}
|