@kontextmind/kxm 0.6.0
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/.claude-plugin/marketplace.json +19 -0
- package/.kxm/README.md +14 -0
- package/.kxm/assets/README.md +5 -0
- package/.kxm/assets/retrospectives/README.md +5 -0
- package/.kxm/config/README.md +5 -0
- package/.kxm/config/agents.json +43 -0
- package/.kxm/config/env.example +56 -0
- package/.kxm/config/update.example.yaml +9 -0
- package/.kxm/config/workflows/fix.json +160 -0
- package/.kxm/config/workflows/jira-development.json +116 -0
- package/.kxm/config/workflows/provenance-quorum.json +150 -0
- package/.kxm/config/workflows/v04-dogfood.json +72 -0
- package/CHANGELOG.md +465 -0
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/SECURITY.md +72 -0
- package/docs/README.md +48 -0
- package/docs/agent-communication-envelopes-and-gates.md +553 -0
- package/docs/architecture.md +242 -0
- package/docs/assignment-runner.md +241 -0
- package/docs/configuration.md +361 -0
- package/docs/continuous-improvement.md +114 -0
- package/docs/getting-started.md +253 -0
- package/docs/kxm-handbook.md +1090 -0
- package/docs/operations.md +205 -0
- package/docs/provenance-gates.md +291 -0
- package/docs/skills.md +45 -0
- package/docs/templates/README.md +95 -0
- package/docs/templates/adr.md +88 -0
- package/docs/templates/architecture.md +120 -0
- package/docs/templates/bug-fix.md +109 -0
- package/docs/templates/feature.md +108 -0
- package/docs/templates/handoff.md +72 -0
- package/docs/templates/postmortem.md +77 -0
- package/docs/templates/research.md +100 -0
- package/docs/templates/review.md +85 -0
- package/docs/templates/runbook.md +73 -0
- package/docs/templates/test-plan.md +87 -0
- package/docs/templates/test-report.md +72 -0
- package/docs/test-matrix.md +121 -0
- package/docs/troubleshooting.md +249 -0
- package/docs/vnext/README.md +62 -0
- package/docs/vnext/architecture.md +185 -0
- package/docs/vnext/effects-and-recovery.md +172 -0
- package/docs/vnext/lifecycles.md +235 -0
- package/docs/vnext/migration.md +220 -0
- package/docs/vnext/routing.md +184 -0
- package/docs/vnext/synchronization.md +172 -0
- package/docs/vnext/terminology.md +240 -0
- package/docs/vnext/validation.md +335 -0
- package/docs/webhook-workflows.md +240 -0
- package/docs/workflow-guide.md +1150 -0
- package/examples/README.md +102 -0
- package/examples/provenance-workflow.json +40 -0
- package/examples/requester.ts +30 -0
- package/examples/reviewer-agent.ts +29 -0
- package/examples/roundtrip.ts +46 -0
- package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
- package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
- package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
- package/examples/vnext/.kxm/agents/planner.yaml +13 -0
- package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
- package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
- package/examples/vnext/.kxm/gates.yaml +8 -0
- package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
- package/examples/vnext/.kxm/models/implementation.yaml +14 -0
- package/examples/vnext/.kxm/models/primary.yaml +17 -0
- package/examples/vnext/.kxm/prices.yaml +111 -0
- package/examples/vnext/.kxm/project/env.yaml +7 -0
- package/examples/vnext/.kxm/project.yaml +32 -0
- package/examples/vnext/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/.kxm/workflows/default.yaml +92 -0
- package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
- package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
- package/examples/vnext/README.md +53 -0
- package/examples/vnext/records/assignment-result-recorded.json +63 -0
- package/examples/vnext/records/assignment-result.json +46 -0
- package/examples/vnext/records/context-candidate.json +42 -0
- package/examples/vnext/records/delivery-manifest.json +66 -0
- package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
- package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
- package/examples/vnext/records/run-created.json +54 -0
- package/examples/vnext/records/sync-event.json +65 -0
- package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
- package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
- package/examples/workflow-signal.ts +63 -0
- package/package.json +129 -0
- package/plugins/kxm/.claude-plugin/plugin.json +73 -0
- package/plugins/kxm/.mcp.json +19 -0
- package/plugins/kxm/README.md +93 -0
- package/plugins/kxm/dist/cli.js +42853 -0
- package/plugins/kxm/dist/client.js +416 -0
- package/plugins/kxm/dist/core.js +1823 -0
- package/plugins/kxm/dist/extension.js +3797 -0
- package/plugins/kxm/dist/mcp-server.js +17104 -0
- package/plugins/kxm/dist/runtime.js +23361 -0
- package/plugins/kxm/dist/server.js +13640 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
- package/plugins/kxm/package.json +12 -0
- package/plugins/kxm/skills/kxm/SKILL.md +97 -0
- package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
- package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
- package/plugins/kxm/src/arbiter.ts +355 -0
- package/plugins/kxm/src/artifacts-exist.ts +62 -0
- package/plugins/kxm/src/autocomplete.ts +236 -0
- package/plugins/kxm/src/cli.ts +3707 -0
- package/plugins/kxm/src/client.ts +614 -0
- package/plugins/kxm/src/commands.ts +1063 -0
- package/plugins/kxm/src/config.ts +290 -0
- package/plugins/kxm/src/context/providers.ts +101 -0
- package/plugins/kxm/src/context-packet.ts +332 -0
- package/plugins/kxm/src/context.ts +499 -0
- package/plugins/kxm/src/core.ts +6 -0
- package/plugins/kxm/src/database.ts +563 -0
- package/plugins/kxm/src/diagnostics.ts +184 -0
- package/plugins/kxm/src/envelope.ts +118 -0
- package/plugins/kxm/src/extension.ts +895 -0
- package/plugins/kxm/src/external-effects.ts +299 -0
- package/plugins/kxm/src/github-watch.ts +255 -0
- package/plugins/kxm/src/hub-binding.ts +160 -0
- package/plugins/kxm/src/hub.ts +2502 -0
- package/plugins/kxm/src/improve.ts +383 -0
- package/plugins/kxm/src/inbox.ts +10 -0
- package/plugins/kxm/src/kxm-install-kind.ts +113 -0
- package/plugins/kxm/src/kxm-update-config.ts +39 -0
- package/plugins/kxm/src/kxm-update.ts +238 -0
- package/plugins/kxm/src/local-snapshot.ts +406 -0
- package/plugins/kxm/src/logger.ts +198 -0
- package/plugins/kxm/src/mcp-server.ts +143 -0
- package/plugins/kxm/src/memory.ts +385 -0
- package/plugins/kxm/src/nous-pi.ts +287 -0
- package/plugins/kxm/src/nous-provider.ts +729 -0
- package/plugins/kxm/src/price-calc.ts +87 -0
- package/plugins/kxm/src/prices.ts +121 -0
- package/plugins/kxm/src/protocol.ts +172 -0
- package/plugins/kxm/src/recovery.ts +211 -0
- package/plugins/kxm/src/redact.ts +26 -0
- package/plugins/kxm/src/retrospective.ts +400 -0
- package/plugins/kxm/src/routing.ts +830 -0
- package/plugins/kxm/src/runtime.ts +9 -0
- package/plugins/kxm/src/server.ts +117 -0
- package/plugins/kxm/src/session-work.ts +571 -0
- package/plugins/kxm/src/session.ts +184 -0
- package/plugins/kxm/src/skills.ts +535 -0
- package/plugins/kxm/src/state.ts +326 -0
- package/plugins/kxm/src/store.ts +637 -0
- package/plugins/kxm/src/studio-layout.ts +268 -0
- package/plugins/kxm/src/suggest.ts +162 -0
- package/plugins/kxm/src/task-manager.ts +244 -0
- package/plugins/kxm/src/telemetry.ts +116 -0
- package/plugins/kxm/src/tui.ts +1046 -0
- package/plugins/kxm/src/vnext-bindings.ts +403 -0
- package/plugins/kxm/src/vnext-config.ts +1646 -0
- package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
- package/plugins/kxm/src/vnext-engine-command.ts +533 -0
- package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
- package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
- package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
- package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
- package/plugins/kxm/src/vnext-engine.ts +2458 -0
- package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
- package/plugins/kxm/src/vnext-harness.ts +1142 -0
- package/plugins/kxm/src/vnext-init.ts +430 -0
- package/plugins/kxm/src/vnext-migrate.ts +1848 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
- package/plugins/kxm/src/vnext-permission.ts +936 -0
- package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
- package/plugins/kxm/src/vnext-repair.ts +1094 -0
- package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
- package/plugins/kxm/src/vnext-runtime.ts +663 -0
- package/plugins/kxm/src/vnext-template.ts +247 -0
- package/plugins/kxm/src/wiki.ts +313 -0
- package/plugins/kxm/src/workflow.ts +1548 -0
- package/schemas/vnext/README.md +46 -0
- package/schemas/vnext/agent.schema.json +40 -0
- package/schemas/vnext/assignment-result.schema.json +66 -0
- package/schemas/vnext/backup-manifest.schema.json +89 -0
- package/schemas/vnext/candidate.schema.json +109 -0
- package/schemas/vnext/common.schema.json +422 -0
- package/schemas/vnext/context-candidate.schema.json +76 -0
- package/schemas/vnext/context-packet.schema.json +192 -0
- package/schemas/vnext/delivery-manifest.schema.json +159 -0
- package/schemas/vnext/environment.schema.json +66 -0
- package/schemas/vnext/gate-registry.schema.json +109 -0
- package/schemas/vnext/handoff-manifest.schema.json +146 -0
- package/schemas/vnext/init-operation.schema.json +61 -0
- package/schemas/vnext/local-repository-bindings.schema.json +30 -0
- package/schemas/vnext/memory-record.schema.json +45 -0
- package/schemas/vnext/migration-decision.schema.json +26 -0
- package/schemas/vnext/migration-plan.schema.json +123 -0
- package/schemas/vnext/migration-receipt.schema.json +52 -0
- package/schemas/vnext/model.schema.json +42 -0
- package/schemas/vnext/permission-diff.schema.json +57 -0
- package/schemas/vnext/prices.schema.json +115 -0
- package/schemas/vnext/project.schema.json +85 -0
- package/schemas/vnext/repository.schema.json +24 -0
- package/schemas/vnext/run-event.schema.json +460 -0
- package/schemas/vnext/session-brief.schema.json +153 -0
- package/schemas/vnext/sync-event.schema.json +234 -0
- package/schemas/vnext/template-provenance.schema.json +38 -0
- package/schemas/vnext/workflow.schema.json +248 -0
- package/scripts/assignment-run.d.mts +354 -0
- package/scripts/assignment-run.mjs +4451 -0
- package/scripts/build-runtime.mjs +56 -0
- package/scripts/check-generated.mjs +77 -0
- package/scripts/check-versions.mjs +34 -0
- package/scripts/emit-codex-artifacts.d.mts +9 -0
- package/scripts/emit-codex-artifacts.mjs +91 -0
- package/scripts/harness-run.d.mts +83 -0
- package/scripts/harness-run.mjs +2095 -0
- package/scripts/kxm-hub.mjs +105 -0
- package/scripts/kxm-publish-npm.mjs +327 -0
- package/scripts/kxm-release-github.mjs +472 -0
- package/scripts/kxm-runtime-supervisor.mjs +7 -0
- package/scripts/kxm-worker.mjs +1127 -0
- package/scripts/kxm.mjs +27 -0
- package/scripts/roster-policy.d.mts +20 -0
- package/scripts/roster-policy.mjs +161 -0
- package/scripts/smoke-multi-pi.mjs +479 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { AGENT_COMMANDS, enforceToolPolicy } from "./commands.ts";
|
|
6
|
+
import { HubClient, HubHttpError } from "./client.ts";
|
|
7
|
+
import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
|
|
8
|
+
import { areaForTool, classifyFailure, diagnosticEvidence, diagnosticSummary, type Diagnostic } from "./diagnostics.ts";
|
|
9
|
+
import {
|
|
10
|
+
MAX_CONTENT_CHARS,
|
|
11
|
+
type DeliveryMode,
|
|
12
|
+
type HubEvent,
|
|
13
|
+
type MessageRecord,
|
|
14
|
+
} from "./protocol.ts";
|
|
15
|
+
import { consumeWorkerRecoveryEnvelope, workerStateKey } from "./recovery.ts";
|
|
16
|
+
import {
|
|
17
|
+
itemFromChoice,
|
|
18
|
+
kxmSlashCompletions,
|
|
19
|
+
loadSessionBrief,
|
|
20
|
+
loadSessionBriefAsync,
|
|
21
|
+
parseKxmSlashArgs,
|
|
22
|
+
sessionBriefChoices,
|
|
23
|
+
sessionBriefPickerEnabled,
|
|
24
|
+
type SessionHubStatus,
|
|
25
|
+
type SessionWorkItem,
|
|
26
|
+
} from "./session-work.ts";
|
|
27
|
+
|
|
28
|
+
const SETTLEMENT_RETRY_BASE_MS = 250;
|
|
29
|
+
const SETTLEMENT_RETRY_MAX_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
function boundedPeerReply(reply: string): string {
|
|
32
|
+
if (reply.length <= MAX_CONTENT_CHARS) return reply;
|
|
33
|
+
const suffix = `\n\n[kxm: response truncated from ${reply.length} characters to fit the message limit; the full output may remain in the replying agent's local session or worker log]`;
|
|
34
|
+
return reply.slice(0, MAX_CONTENT_CHARS - suffix.length) + suffix;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isTerminalMessage(message: MessageRecord): boolean {
|
|
38
|
+
return message.status === "replied"
|
|
39
|
+
|| message.status === "cancelled"
|
|
40
|
+
|| message.status === "expired"
|
|
41
|
+
|| message.status === "error";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isTerminalMessageError(error: unknown): boolean {
|
|
45
|
+
return error instanceof HubHttpError
|
|
46
|
+
&& (error.statusCode === 409
|
|
47
|
+
|| (error.statusCode === 404 && error.code === "message_not_found"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const WORKFLOW_RUN_ID = /^run_[a-f0-9]{32}$/;
|
|
51
|
+
const PI_SESSION_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
52
|
+
|
|
53
|
+
type WorkerSessionBinding = { kind: "default" } | { kind: "workflow"; runId: string };
|
|
54
|
+
|
|
55
|
+
/** Return only hub-authorized workflow affinity. The legacy internal-message
|
|
56
|
+
* fallback supports durable messages created before workflowRunId was added;
|
|
57
|
+
* caller-controlled correlation IDs alone never establish affinity. */
|
|
58
|
+
export function workflowRunIdForMessage(message: Pick<MessageRecord, "workflowRunId" | "workflowContext" | "correlationId" | "from">): string | undefined {
|
|
59
|
+
if (message.workflowRunId !== undefined) {
|
|
60
|
+
if (!WORKFLOW_RUN_ID.test(message.workflowRunId)) return undefined;
|
|
61
|
+
if (message.workflowContext?.runId && message.workflowContext.runId !== message.workflowRunId) return undefined;
|
|
62
|
+
return message.workflowRunId;
|
|
63
|
+
}
|
|
64
|
+
if (message.workflowContext?.runId && WORKFLOW_RUN_ID.test(message.workflowContext.runId)) {
|
|
65
|
+
return message.workflowContext.runId;
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
message.correlationId
|
|
69
|
+
&& WORKFLOW_RUN_ID.test(message.correlationId)
|
|
70
|
+
&& message.from === `workflow:${message.correlationId}`
|
|
71
|
+
) return message.correlationId;
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function bindingForMessage(message: Pick<MessageRecord, "workflowRunId" | "workflowContext" | "correlationId" | "from">): WorkerSessionBinding {
|
|
76
|
+
const runId = workflowRunIdForMessage(message);
|
|
77
|
+
if (message.workflowRunId !== undefined && !runId) {
|
|
78
|
+
throw new Error("hub workflow affinity is malformed or conflicts with workflow context");
|
|
79
|
+
}
|
|
80
|
+
if (message.workflowContext?.runId && !WORKFLOW_RUN_ID.test(message.workflowContext.runId)) {
|
|
81
|
+
throw new Error("hub workflow context contains a malformed run identity");
|
|
82
|
+
}
|
|
83
|
+
return runId ? { kind: "workflow", runId } : { kind: "default" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function bindingFromEnvironment(): WorkerSessionBinding {
|
|
87
|
+
if (process.env.KXM_WORKER_SESSION_ISOLATION !== "workflow") return { kind: "default" };
|
|
88
|
+
const scope = process.env.KXM_WORKER_SESSION_SCOPE?.trim();
|
|
89
|
+
if (scope === "default") return { kind: "default" };
|
|
90
|
+
if (scope?.startsWith("workflow:")) {
|
|
91
|
+
const runId = scope.slice("workflow:".length);
|
|
92
|
+
if (WORKFLOW_RUN_ID.test(runId)) return { kind: "workflow", runId };
|
|
93
|
+
}
|
|
94
|
+
throw new Error("KXM_WORKER_SESSION_SCOPE must be default or workflow:<canonical-run-id>");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sameBinding(left: WorkerSessionBinding, right: WorkerSessionBinding): boolean {
|
|
98
|
+
return left.kind === right.kind
|
|
99
|
+
&& (left.kind === "default" || (right.kind === "workflow" && left.runId === right.runId));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function result(value: unknown) {
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
|
105
|
+
details: value,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function latestAssistantMessage(messages: unknown[]): {
|
|
110
|
+
content?: unknown;
|
|
111
|
+
errorMessage?: unknown;
|
|
112
|
+
role?: unknown;
|
|
113
|
+
stopReason?: unknown;
|
|
114
|
+
} | undefined {
|
|
115
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
116
|
+
const message = messages[index] as { role?: unknown };
|
|
117
|
+
if (message?.role === "assistant") return messages[index] as ReturnType<typeof latestAssistantMessage>;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assistantText(messages: unknown[]): string | undefined {
|
|
123
|
+
const message = latestAssistantMessage(messages);
|
|
124
|
+
if (!message) return undefined;
|
|
125
|
+
if (typeof message.content === "string") return message.content.trim();
|
|
126
|
+
if (!Array.isArray(message.content)) return undefined;
|
|
127
|
+
const text = message.content
|
|
128
|
+
.map((item) => {
|
|
129
|
+
const part = item as { type?: string; text?: string };
|
|
130
|
+
return part.type === "text" ? part.text ?? "" : "";
|
|
131
|
+
})
|
|
132
|
+
.join("")
|
|
133
|
+
.trim();
|
|
134
|
+
return text || undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function assistantFailure(messages: unknown[]): Diagnostic | undefined {
|
|
138
|
+
const message = latestAssistantMessage(messages);
|
|
139
|
+
if (!message || (message.stopReason !== "error" && message.stopReason !== "aborted")) return undefined;
|
|
140
|
+
if (message.stopReason === "aborted") {
|
|
141
|
+
return classifyFailure({
|
|
142
|
+
toolName: "provider",
|
|
143
|
+
message: "cancelled",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const failureMessage = typeof message.errorMessage === "string" ? message.errorMessage : "provider error";
|
|
147
|
+
const quota = /\b(quota reached|quota exceeded|rate limit(?:ed)?|too many requests|resource exhausted|http 429)\b/i.test(failureMessage);
|
|
148
|
+
return classifyFailure({
|
|
149
|
+
toolName: "provider",
|
|
150
|
+
code: quota ? "provider_quota" : "provider_error",
|
|
151
|
+
message: failureMessage,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void> {
|
|
156
|
+
let client: HubClient | undefined;
|
|
157
|
+
let nousReport: NousRegistrationReport | undefined;
|
|
158
|
+
let pending: MessageRecord[] = [];
|
|
159
|
+
let activatingInbound: MessageRecord | undefined;
|
|
160
|
+
let awaitingActivation: MessageRecord | undefined;
|
|
161
|
+
let activeInbound: MessageRecord | undefined;
|
|
162
|
+
let activationInProgress = false;
|
|
163
|
+
let activationPromise: Promise<void> | undefined;
|
|
164
|
+
let activationRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
165
|
+
let activationRetryAttempt = 0;
|
|
166
|
+
let activationStartTimer: ReturnType<typeof setTimeout> | undefined;
|
|
167
|
+
let activationStartMessageId: string | undefined;
|
|
168
|
+
let requestWorkerRestart: (() => void) | undefined;
|
|
169
|
+
let activeReply: string | undefined;
|
|
170
|
+
let settlementReply: string | undefined;
|
|
171
|
+
let activeTurnSettled = false;
|
|
172
|
+
let settlementInProgress = false;
|
|
173
|
+
let settlementRetryAttempt = 0;
|
|
174
|
+
let settlementRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
175
|
+
let activeFailure: Diagnostic | undefined;
|
|
176
|
+
let activeFailureRecorded = false;
|
|
177
|
+
let shuttingDown = false;
|
|
178
|
+
let notify: ((message: string, type: "error") => void) | undefined;
|
|
179
|
+
let stateDir = process.env.KXM_STATE_DIR ?? "";
|
|
180
|
+
let agentName = process.env.KXM_AGENT_NAME ?? "";
|
|
181
|
+
let projectName = process.env.KXM_PROJECT ?? "";
|
|
182
|
+
let recoveryStageId: string | undefined;
|
|
183
|
+
let recoveryArtifacts: string[] = [];
|
|
184
|
+
let currentPiSessionId: string | undefined;
|
|
185
|
+
let currentSessionBinding: WorkerSessionBinding = { kind: "default" };
|
|
186
|
+
|
|
187
|
+
function recoveryContextPath(): string | undefined {
|
|
188
|
+
if (!stateDir || !agentName || !projectName) return undefined;
|
|
189
|
+
return join(stateDir, `worker-context-${workerStateKey(projectName, agentName)}.json`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sessionRouteRequestPath(): string | undefined {
|
|
193
|
+
const workerKey = process.env.KXM_WORKER_IDENTITY_KEY?.trim();
|
|
194
|
+
if (!stateDir || !workerKey) return undefined;
|
|
195
|
+
return join(stateDir, `worker-session-request-${workerKey}.json`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function requestSessionRoute(message: MessageRecord, target: WorkerSessionBinding): boolean {
|
|
199
|
+
if (process.env.KXM_WORKER_SESSION_ISOLATION !== "workflow" || sameBinding(currentSessionBinding, target)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const path = sessionRouteRequestPath();
|
|
203
|
+
const workerKey = process.env.KXM_WORKER_IDENTITY_KEY?.trim();
|
|
204
|
+
const generation = process.env.KXM_WORKER_GENERATION?.trim();
|
|
205
|
+
const childIncarnation = Number(process.env.KXM_WORKER_CHILD_INCARCATION);
|
|
206
|
+
if (!path || !workerKey || !generation || !Number.isInteger(childIncarnation) || childIncarnation < 1 || !currentPiSessionId || !PI_SESSION_ID.test(currentPiSessionId)) {
|
|
207
|
+
throw new Error("supervised workflow session routing is missing a valid worker generation, child incarnation, or Pi session identity");
|
|
208
|
+
}
|
|
209
|
+
const request = {
|
|
210
|
+
version: 1,
|
|
211
|
+
agentName,
|
|
212
|
+
project: projectName,
|
|
213
|
+
workerKey,
|
|
214
|
+
generation,
|
|
215
|
+
childIncarnation,
|
|
216
|
+
from: currentSessionBinding,
|
|
217
|
+
to: target,
|
|
218
|
+
sourceSessionId: currentPiSessionId,
|
|
219
|
+
messageId: message.id,
|
|
220
|
+
createdAt: new Date().toISOString(),
|
|
221
|
+
};
|
|
222
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
223
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
224
|
+
writeFileSync(tmp, `${JSON.stringify(request)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
225
|
+
renameSync(tmp, path);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function removeMatchingLegacyRecoveryContext(): void {
|
|
230
|
+
if (!stateDir || !agentName || !projectName) return;
|
|
231
|
+
const legacy = join(stateDir, `worker-context-${agentName.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
232
|
+
if (legacy === recoveryContextPath()) return;
|
|
233
|
+
try {
|
|
234
|
+
const candidate = JSON.parse(readFileSync(legacy, "utf8")) as { version?: number; agentName?: string; project?: string };
|
|
235
|
+
if (candidate.version === 1 && candidate.agentName === agentName && candidate.project === projectName) {
|
|
236
|
+
rmSync(legacy, { force: true });
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
// A missing, malformed, or differently-owned legacy context is not ours.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function persistRecoveryContext(): void {
|
|
244
|
+
const path = recoveryContextPath();
|
|
245
|
+
if (!path) return;
|
|
246
|
+
// Bind recovery only to work that entered activation in this Pi scope.
|
|
247
|
+
// Merely queued future workflow work must not receive a failure from the
|
|
248
|
+
// current ordinary or sibling-run turn.
|
|
249
|
+
const recoveryMessage = [activeInbound, awaitingActivation, activatingInbound]
|
|
250
|
+
.find((message) => message && workflowRunIdForMessage(message));
|
|
251
|
+
const runId = recoveryMessage ? workflowRunIdForMessage(recoveryMessage) : undefined;
|
|
252
|
+
const activeMessageIds = [activeInbound?.id, awaitingActivation?.id, activatingInbound?.id]
|
|
253
|
+
.filter((id): id is string => Boolean(id)).slice(0, 3);
|
|
254
|
+
const pendingMessageIds = [...activeMessageIds, ...pending.map((message) => message.id)]
|
|
255
|
+
.filter((id, index, values): id is string => Boolean(id) && values.indexOf(id) === index).slice(0, 16);
|
|
256
|
+
if (!runId && pendingMessageIds.length === 0) {
|
|
257
|
+
rmSync(path, { force: true });
|
|
258
|
+
removeMatchingLegacyRecoveryContext();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
262
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
263
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, agentName, project: projectName, runId: runId ?? null, stageId: recoveryStageId ?? null, activeMessageIds, pendingMessageIds, artifactPointers: recoveryArtifacts.slice(0, 16), updatedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
|
|
264
|
+
renameSync(tmp, path);
|
|
265
|
+
removeMatchingLegacyRecoveryContext();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function workflowCall<T>(operation: () => Promise<T>): Promise<T> {
|
|
269
|
+
try { return await operation(); } catch (error) {
|
|
270
|
+
if (!(error instanceof HubHttpError)) throw error;
|
|
271
|
+
const assigned = typeof error.extras?.assignedCoordinatorName === "string" ? ` assignedCoordinator=${error.extras.assignedCoordinatorName}` : "";
|
|
272
|
+
const nextAction = typeof error.extras?.nextAction === "string" ? ` nextAction=${error.extras.nextAction}` : "";
|
|
273
|
+
const operationName = typeof error.extras?.operation === "string" ? ` operation=${error.extras.operation}` : "";
|
|
274
|
+
throw new Error(`${error.message} [code=${error.code ?? "http_error"}${operationName}${assigned}${nextAction}]`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function requireClient(): HubClient {
|
|
279
|
+
if (!client?.agent) throw new Error("kxm hub is not connected; check KXM_SERVER_URL and /kxm hub");
|
|
280
|
+
return client;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let currentWork: SessionWorkItem | undefined;
|
|
284
|
+
|
|
285
|
+
async function applySessionChrome(
|
|
286
|
+
ctx: {
|
|
287
|
+
cwd?: string;
|
|
288
|
+
mode?: string;
|
|
289
|
+
ui: {
|
|
290
|
+
setStatus?: (key: string, value: string | undefined) => void;
|
|
291
|
+
setWidget?: (key: string, lines: string[] | undefined) => void;
|
|
292
|
+
setEditorText?: (text: string) => void;
|
|
293
|
+
select?: (title: string, options: string[]) => Promise<string | undefined>;
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
event: { reason?: string },
|
|
297
|
+
offerPicker: boolean,
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
const cwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
300
|
+
const hub: SessionHubStatus | undefined = client?.agent
|
|
301
|
+
? { state: "on", evidence: "process", online: true }
|
|
302
|
+
: undefined;
|
|
303
|
+
const brief = await loadSessionBriefAsync(cwd, process.env, currentWork, hub);
|
|
304
|
+
ctx.ui.setStatus?.("kxm", brief.statusLine);
|
|
305
|
+
ctx.ui.setWidget?.("kxm-work", brief.widgetLines);
|
|
306
|
+
if (!offerPicker || !sessionBriefPickerEnabled({
|
|
307
|
+
env: process.env,
|
|
308
|
+
...(ctx.mode ? { mode: ctx.mode } : {}),
|
|
309
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
310
|
+
})) return;
|
|
311
|
+
if (brief.tasks.length === 0 && brief.plans.length === 0) return;
|
|
312
|
+
if (typeof ctx.ui.select !== "function") return;
|
|
313
|
+
const choice = await ctx.ui.select("Continue KXM work?", sessionBriefChoices(brief));
|
|
314
|
+
const item = itemFromChoice(brief, choice);
|
|
315
|
+
if (!item) return;
|
|
316
|
+
currentWork = item;
|
|
317
|
+
const selected = await loadSessionBriefAsync(cwd, process.env, item, hub);
|
|
318
|
+
ctx.ui.setStatus?.("kxm", selected.statusLine);
|
|
319
|
+
ctx.ui.setWidget?.("kxm-work", selected.widgetLines);
|
|
320
|
+
ctx.ui.setEditorText?.(item.prompt);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function showKxmHub(ctx: { ui: { notify: (message: string, type: "info" | "warning" | "error") => void } }): Promise<void> {
|
|
324
|
+
const url = (process.env.KXM_SERVER_URL ?? "http://127.0.0.1:7331").replace(/\/$/, "");
|
|
325
|
+
let health = "unreachable";
|
|
326
|
+
try {
|
|
327
|
+
const response = await fetch(`${url}/health`);
|
|
328
|
+
health = response.ok ? "ok" : `http_${response.status}`;
|
|
329
|
+
} catch {
|
|
330
|
+
health = "unreachable";
|
|
331
|
+
}
|
|
332
|
+
if (!client?.agent) {
|
|
333
|
+
ctx.ui.notify(`kxm hub view: health=${health}; no agent connected`, "warning");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const peers = await client.listAgents();
|
|
337
|
+
ctx.ui.notify(`kxm hub view: health=${health}; ${client.agent.name}; ${peers.length} online agent(s)`, "info");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function clearActivationWatchdog(messageId?: string): void {
|
|
341
|
+
if (messageId && activationStartMessageId !== messageId) return;
|
|
342
|
+
if (activationStartTimer) clearTimeout(activationStartTimer);
|
|
343
|
+
activationStartTimer = undefined;
|
|
344
|
+
activationStartMessageId = undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function startActivationWatchdog(messageId: string): void {
|
|
348
|
+
clearActivationWatchdog();
|
|
349
|
+
if (!process.env.KXM_WORKER_IDENTITY_KEY) return;
|
|
350
|
+
const configured = Number(process.env.KXM_WORKER_ACTIVATION_TIMEOUT_MS?.trim() || 60_000);
|
|
351
|
+
const timeoutMs = Number.isInteger(configured) && configured >= 1_000 && configured <= 600_000
|
|
352
|
+
? configured
|
|
353
|
+
: 60_000;
|
|
354
|
+
activationStartMessageId = messageId;
|
|
355
|
+
activationStartTimer = setTimeout(() => {
|
|
356
|
+
activationStartTimer = undefined;
|
|
357
|
+
if (shuttingDown || activeInbound || awaitingActivation?.id !== messageId) return;
|
|
358
|
+
notify?.(
|
|
359
|
+
`kxm worker is stuck: delivered message ${messageId} did not start a model turn within ${timeoutMs}ms; requesting supervised restart`,
|
|
360
|
+
"error",
|
|
361
|
+
);
|
|
362
|
+
persistRecoveryContext();
|
|
363
|
+
requestWorkerRestart?.();
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
activationStartTimer.unref?.();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function enqueue(message: MessageRecord, front = false): void {
|
|
369
|
+
if (front) {
|
|
370
|
+
pending.unshift(message);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const priority = (delivery: DeliveryMode): number => delivery === "steer" ? 0 : delivery === "followUp" ? 1 : 2;
|
|
374
|
+
const messagePriority = priority(message.delivery);
|
|
375
|
+
const firstLowerPriority = pending.findIndex((candidate) => priority(candidate.delivery) > messagePriority);
|
|
376
|
+
if (firstLowerPriority < 0) pending.push(message);
|
|
377
|
+
else pending.splice(firstLowerPriority, 0, message);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function requestActivation(): void {
|
|
381
|
+
if (activationPromise || shuttingDown) return;
|
|
382
|
+
const task = activateNext();
|
|
383
|
+
activationPromise = task;
|
|
384
|
+
void task.finally(() => {
|
|
385
|
+
if (activationPromise === task) activationPromise = undefined;
|
|
386
|
+
if (
|
|
387
|
+
!shuttingDown
|
|
388
|
+
&& !activationRetryTimer
|
|
389
|
+
&& !activatingInbound
|
|
390
|
+
&& !awaitingActivation
|
|
391
|
+
&& !activeInbound
|
|
392
|
+
&& pending.length > 0
|
|
393
|
+
) requestActivation();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function scheduleActivationRetry(error: unknown): void {
|
|
398
|
+
if (shuttingDown || activationRetryTimer) return;
|
|
399
|
+
const delayMs = Math.min(
|
|
400
|
+
SETTLEMENT_RETRY_BASE_MS * (2 ** Math.min(activationRetryAttempt, 7)),
|
|
401
|
+
SETTLEMENT_RETRY_MAX_MS,
|
|
402
|
+
);
|
|
403
|
+
activationRetryAttempt += 1;
|
|
404
|
+
notify?.(
|
|
405
|
+
`kxm could not activate the next hub message; it remains durable and activation will retry in ${delayMs}ms: ${error instanceof Error ? error.message : String(error)}`,
|
|
406
|
+
"error",
|
|
407
|
+
);
|
|
408
|
+
activationRetryTimer = setTimeout(() => {
|
|
409
|
+
activationRetryTimer = undefined;
|
|
410
|
+
requestActivation();
|
|
411
|
+
}, delayMs);
|
|
412
|
+
activationRetryTimer.unref?.();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function activateNext(): Promise<void> {
|
|
416
|
+
if (
|
|
417
|
+
shuttingDown
|
|
418
|
+
|| !client
|
|
419
|
+
|| activationInProgress
|
|
420
|
+
|| activationRetryTimer
|
|
421
|
+
|| activatingInbound
|
|
422
|
+
|| awaitingActivation
|
|
423
|
+
|| activeInbound
|
|
424
|
+
) return;
|
|
425
|
+
activationInProgress = true;
|
|
426
|
+
try {
|
|
427
|
+
let message = pending.shift();
|
|
428
|
+
while (message && (isTerminalMessage(message) || Date.parse(message.expiresAt) <= Date.now())) {
|
|
429
|
+
message = pending.shift();
|
|
430
|
+
}
|
|
431
|
+
if (!message) {
|
|
432
|
+
persistRecoveryContext();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const targetBinding = bindingForMessage(message);
|
|
437
|
+
if (requestSessionRoute(message, targetBinding)) {
|
|
438
|
+
// The hub remains authoritative: do not acknowledge before the
|
|
439
|
+
// destination Pi session is active. The replacement extension will
|
|
440
|
+
// receive this still-queued message after the supervisor swaps the
|
|
441
|
+
// single child process.
|
|
442
|
+
enqueue(message, true);
|
|
443
|
+
persistRecoveryContext();
|
|
444
|
+
shuttingDown = true;
|
|
445
|
+
queueMicrotask(() => requestWorkerRestart?.());
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
enqueue(message, true);
|
|
450
|
+
persistRecoveryContext();
|
|
451
|
+
scheduleActivationRetry(error);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
activatingInbound = message;
|
|
455
|
+
persistRecoveryContext();
|
|
456
|
+
let acknowledged: MessageRecord;
|
|
457
|
+
try {
|
|
458
|
+
acknowledged = await client.acknowledge(message.id);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (isTerminalMessageError(error)) {
|
|
461
|
+
activatingInbound = undefined;
|
|
462
|
+
activationRetryAttempt = 0;
|
|
463
|
+
persistRecoveryContext();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (activatingInbound?.id === message.id) {
|
|
467
|
+
activatingInbound = undefined;
|
|
468
|
+
enqueue(message, true);
|
|
469
|
+
persistRecoveryContext();
|
|
470
|
+
}
|
|
471
|
+
scheduleActivationRetry(error);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (activatingInbound?.id !== message.id || shuttingDown) return;
|
|
475
|
+
activationRetryAttempt = 0;
|
|
476
|
+
activatingInbound = undefined;
|
|
477
|
+
awaitingActivation = acknowledged;
|
|
478
|
+
persistRecoveryContext();
|
|
479
|
+
startActivationWatchdog(acknowledged.id);
|
|
480
|
+
try {
|
|
481
|
+
pi.sendMessage({
|
|
482
|
+
customType: "kxm-inbound",
|
|
483
|
+
content: [
|
|
484
|
+
`Peer request from ${acknowledged.fromName} (message ${acknowledged.id}):`,
|
|
485
|
+
"",
|
|
486
|
+
acknowledged.content,
|
|
487
|
+
"",
|
|
488
|
+
"Respond directly to the peer request. Your settled final response will be returned automatically.",
|
|
489
|
+
].join("\n"),
|
|
490
|
+
display: true,
|
|
491
|
+
details: { messageId: acknowledged.id, from: acknowledged.fromName },
|
|
492
|
+
}, {
|
|
493
|
+
// Pi's nextTurn mode deliberately waits for a future human prompt. An
|
|
494
|
+
// autonomous worker has no such prompt, so its durable next item is
|
|
495
|
+
// normalized to an immediately-triggered follow-up turn.
|
|
496
|
+
triggerTurn: true,
|
|
497
|
+
deliverAs: acknowledged.delivery === "nextTurn" ? "followUp" : acknowledged.delivery,
|
|
498
|
+
});
|
|
499
|
+
} catch (error) {
|
|
500
|
+
clearActivationWatchdog(acknowledged.id);
|
|
501
|
+
awaitingActivation = undefined;
|
|
502
|
+
enqueue(acknowledged, true);
|
|
503
|
+
persistRecoveryContext();
|
|
504
|
+
scheduleActivationRetry(error);
|
|
505
|
+
}
|
|
506
|
+
} finally {
|
|
507
|
+
activationInProgress = false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function receive(event: HubEvent): Promise<void> {
|
|
512
|
+
if (shuttingDown) return;
|
|
513
|
+
if (event.type === "message") {
|
|
514
|
+
if (
|
|
515
|
+
activeInbound?.id === event.message.id
|
|
516
|
+
|| awaitingActivation?.id === event.message.id
|
|
517
|
+
|| activatingInbound?.id === event.message.id
|
|
518
|
+
) return;
|
|
519
|
+
if (pending.some((message) => message.id === event.message.id)) {
|
|
520
|
+
requestActivation();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (isTerminalMessage(event.message) || Date.parse(event.message.expiresAt) <= Date.now()) return;
|
|
524
|
+
if (shuttingDown) return;
|
|
525
|
+
// The hub remains the durable queue. Waiting messages stay queued there;
|
|
526
|
+
// only activateNext acknowledges the single item entering a model turn.
|
|
527
|
+
enqueue(event.message);
|
|
528
|
+
persistRecoveryContext();
|
|
529
|
+
requestActivation();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (event.type !== "cancelled" && event.type !== "expired" && event.type !== "reply") return;
|
|
534
|
+
const messageId = event.message.id;
|
|
535
|
+
const pendingLength = pending.length;
|
|
536
|
+
pending = pending.filter((message) => message.id !== messageId);
|
|
537
|
+
let changed = pending.length !== pendingLength;
|
|
538
|
+
if (activatingInbound?.id === messageId) {
|
|
539
|
+
activatingInbound = undefined;
|
|
540
|
+
changed = true;
|
|
541
|
+
}
|
|
542
|
+
if (awaitingActivation?.id === messageId) {
|
|
543
|
+
clearActivationWatchdog(messageId);
|
|
544
|
+
awaitingActivation = undefined;
|
|
545
|
+
changed = true;
|
|
546
|
+
}
|
|
547
|
+
if (activeInbound?.id === messageId) {
|
|
548
|
+
activeInbound = event.message;
|
|
549
|
+
changed = true;
|
|
550
|
+
}
|
|
551
|
+
if (!changed) return;
|
|
552
|
+
persistRecoveryContext();
|
|
553
|
+
if (activeInbound?.id === messageId && activeTurnSettled && !settlementInProgress) {
|
|
554
|
+
finishActiveInbound(messageId);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
requestActivation();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function finishActiveInbound(messageId: string): void {
|
|
561
|
+
if (activeInbound?.id !== messageId) return;
|
|
562
|
+
if (settlementRetryTimer) clearTimeout(settlementRetryTimer);
|
|
563
|
+
settlementRetryTimer = undefined;
|
|
564
|
+
settlementRetryAttempt = 0;
|
|
565
|
+
clearActivationWatchdog(messageId);
|
|
566
|
+
activeInbound = undefined;
|
|
567
|
+
activeReply = undefined;
|
|
568
|
+
settlementReply = undefined;
|
|
569
|
+
activeTurnSettled = false;
|
|
570
|
+
activeFailure = undefined;
|
|
571
|
+
activeFailureRecorded = false;
|
|
572
|
+
recoveryStageId = undefined;
|
|
573
|
+
recoveryArtifacts = [];
|
|
574
|
+
persistRecoveryContext();
|
|
575
|
+
if (!shuttingDown) requestActivation();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function scheduleSettlementRetry(messageId: string, error: unknown): void {
|
|
579
|
+
if (shuttingDown || !client || activeInbound?.id !== messageId || settlementRetryTimer) return;
|
|
580
|
+
const delayMs = Math.min(
|
|
581
|
+
SETTLEMENT_RETRY_BASE_MS * (2 ** Math.min(settlementRetryAttempt, 7)),
|
|
582
|
+
SETTLEMENT_RETRY_MAX_MS,
|
|
583
|
+
);
|
|
584
|
+
settlementRetryAttempt += 1;
|
|
585
|
+
persistRecoveryContext();
|
|
586
|
+
notify?.(
|
|
587
|
+
`kxm could not return reply for ${messageId}; recovery state was retained and settlement will retry in ${delayMs}ms: ${error instanceof Error ? error.message : String(error)}`,
|
|
588
|
+
"error",
|
|
589
|
+
);
|
|
590
|
+
settlementRetryTimer = setTimeout(() => {
|
|
591
|
+
settlementRetryTimer = undefined;
|
|
592
|
+
void settleActiveInbound(messageId);
|
|
593
|
+
}, delayMs);
|
|
594
|
+
settlementRetryTimer.unref?.();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function settleActiveInbound(messageId: string): Promise<void> {
|
|
598
|
+
if (!activeInbound || activeInbound.id !== messageId || !client || settlementInProgress) return;
|
|
599
|
+
settlementInProgress = true;
|
|
600
|
+
const message = activeInbound;
|
|
601
|
+
const activeClient = client;
|
|
602
|
+
const reply = settlementReply
|
|
603
|
+
?? boundedPeerReply(activeReply ?? "The peer agent completed without a textual response.");
|
|
604
|
+
try {
|
|
605
|
+
let current: MessageRecord;
|
|
606
|
+
try {
|
|
607
|
+
current = await activeClient.getMessage(message.id);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (isTerminalMessageError(error)) {
|
|
610
|
+
finishActiveInbound(message.id);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
if (isTerminalMessage(current)) {
|
|
616
|
+
finishActiveInbound(message.id);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
await activeClient.reply(message.id, reply);
|
|
620
|
+
finishActiveInbound(message.id);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (isTerminalMessageError(error)) {
|
|
623
|
+
finishActiveInbound(message.id);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
scheduleSettlementRetry(message.id, error);
|
|
627
|
+
} finally {
|
|
628
|
+
settlementInProgress = false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
pi.on("session_start", async (event: { reason?: string }, ctx) => {
|
|
633
|
+
shuttingDown = false;
|
|
634
|
+
if (nousReport?.guidance.length) {
|
|
635
|
+
for (const item of nousReport.guidance) {
|
|
636
|
+
ctx.ui.notify(item.message, item.level);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
await client?.stop();
|
|
640
|
+
client = undefined;
|
|
641
|
+
try {
|
|
642
|
+
currentSessionBinding = bindingFromEnvironment();
|
|
643
|
+
} catch (error) {
|
|
644
|
+
await applySessionChrome(ctx, event, false);
|
|
645
|
+
ctx.ui.notify(`kxm session routing configuration failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
646
|
+
void ctx.shutdown();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
currentPiSessionId = ctx.sessionManager?.getSessionId();
|
|
650
|
+
const serverUrl = process.env.KXM_SERVER_URL ?? "http://127.0.0.1:7331";
|
|
651
|
+
const project = process.env.KXM_PROJECT ?? basename(ctx.cwd);
|
|
652
|
+
const name = process.env.KXM_AGENT_NAME ?? pi.getSessionName() ?? `pi-${process.pid}`;
|
|
653
|
+
agentName = name;
|
|
654
|
+
projectName = project;
|
|
655
|
+
stateDir = process.env.KXM_STATE_DIR ?? stateDir;
|
|
656
|
+
removeMatchingLegacyRecoveryContext();
|
|
657
|
+
const purpose = process.env.KXM_AGENT_PURPOSE ?? "General-purpose coding agent";
|
|
658
|
+
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
659
|
+
client = new HubClient({
|
|
660
|
+
serverUrl,
|
|
661
|
+
name,
|
|
662
|
+
purpose,
|
|
663
|
+
project,
|
|
664
|
+
...(process.env.KXM_AUTH_TOKEN ? { authToken: process.env.KXM_AUTH_TOKEN } : {}),
|
|
665
|
+
...(model ? { model } : {}),
|
|
666
|
+
});
|
|
667
|
+
notify = (message, type) => ctx.ui.notify(message, type);
|
|
668
|
+
requestWorkerRestart = () => { void ctx.shutdown(); };
|
|
669
|
+
try {
|
|
670
|
+
const agent = await client.start(receive);
|
|
671
|
+
ctx.ui.notify(`Connected to the KXM hub as ${agent.name}`, "info");
|
|
672
|
+
const recovered = stateDir ? await consumeWorkerRecoveryEnvelope(client, stateDir, agent.name, project) : undefined;
|
|
673
|
+
const recoveryReplayIds = recovered?.activeMessageIds ?? recovered?.pendingMessageIds ?? [];
|
|
674
|
+
const replayCandidates = await Promise.all(recoveryReplayIds.slice(0, 16).map(async (messageId) => {
|
|
675
|
+
try {
|
|
676
|
+
const message = await client!.getMessage(messageId);
|
|
677
|
+
return message.status === "queued" || message.status === "delivered";
|
|
678
|
+
} catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}));
|
|
682
|
+
const durableInboundWillReplay = replayCandidates.some(Boolean);
|
|
683
|
+
const recoveryMatchesSession = process.env.KXM_WORKER_SESSION_ISOLATION !== "workflow"
|
|
684
|
+
|| (currentSessionBinding.kind === "workflow" && recovered?.runId === currentSessionBinding.runId);
|
|
685
|
+
if (recovered?.freshSession && recovered.runId && !recovered.peerLocal && recoveryMatchesSession && !durableInboundWillReplay) {
|
|
686
|
+
pi.sendMessage({ customType: "kxm-recovery", content: [`Resume durable workflow run ${recovered.runId} after a fresh-session worker recovery.`, recovered.stageId ? `Last recorded stage: ${recovered.stageId}.` : "Resolve the current stage from kxm_workflow_get.", `Recovery reason: ${recovered.reason}.`, "Call kxm_workflow_get, inspect its journal and stage evidence, then continue the current stage without repeating completed work.", "Record the recovery decision and checkpoint only after the required evidence is satisfied."].join("\n"), display: true, details: { runId: recovered.runId, stageId: recovered.stageId, reason: recovered.reason } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
687
|
+
await applySessionChrome(ctx, event, false);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
client = undefined;
|
|
692
|
+
ctx.ui.notify(`kxm connection failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
693
|
+
}
|
|
694
|
+
await applySessionChrome(ctx, event, true);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
pi.on("message_start", (event) => {
|
|
698
|
+
const message = event.message as { customType?: string; details?: { messageId?: string } };
|
|
699
|
+
if (message.customType !== "kxm-inbound" || !awaitingActivation) return;
|
|
700
|
+
if (message.details?.messageId !== awaitingActivation.id) return;
|
|
701
|
+
clearActivationWatchdog(awaitingActivation.id);
|
|
702
|
+
activeInbound = awaitingActivation;
|
|
703
|
+
awaitingActivation = undefined;
|
|
704
|
+
activeReply = undefined;
|
|
705
|
+
settlementReply = undefined;
|
|
706
|
+
activeTurnSettled = false;
|
|
707
|
+
activeFailure = undefined;
|
|
708
|
+
activeFailureRecorded = false;
|
|
709
|
+
settlementRetryAttempt = 0;
|
|
710
|
+
recoveryStageId = undefined;
|
|
711
|
+
recoveryArtifacts = [];
|
|
712
|
+
persistRecoveryContext();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
pi.on("agent_end", (event) => {
|
|
716
|
+
if (!activeInbound || activeTurnSettled) return;
|
|
717
|
+
const latest = latestAssistantMessage(event.messages as unknown[]);
|
|
718
|
+
if (!latest) return;
|
|
719
|
+
const failure = assistantFailure(event.messages as unknown[]);
|
|
720
|
+
if (failure) {
|
|
721
|
+
activeFailure = failure;
|
|
722
|
+
activeFailureRecorded = false;
|
|
723
|
+
activeReply = undefined;
|
|
724
|
+
persistRecoveryContext();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
activeFailure = undefined;
|
|
728
|
+
activeFailureRecorded = false;
|
|
729
|
+
activeReply = assistantText(event.messages as unknown[]) ?? activeReply;
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
pi.on("tool_result", async (event) => {
|
|
733
|
+
const activeRunId = activeInbound ? workflowRunIdForMessage(activeInbound) : undefined;
|
|
734
|
+
if (!activeRunId || !client) return;
|
|
735
|
+
const resultEvent = event as {
|
|
736
|
+
toolName?: string;
|
|
737
|
+
toolCallId?: string;
|
|
738
|
+
isError?: boolean;
|
|
739
|
+
text?: string;
|
|
740
|
+
error?: string;
|
|
741
|
+
code?: string;
|
|
742
|
+
statusCode?: number;
|
|
743
|
+
details?: unknown;
|
|
744
|
+
};
|
|
745
|
+
const serializedDetails = JSON.stringify(resultEvent.details ?? {});
|
|
746
|
+
const stageMatch = serializedDetails.match(/"(?:currentStage|stageId)"\s*:\s*"([A-Za-z0-9_.-]{1,64})"/);
|
|
747
|
+
if (stageMatch?.[1]) recoveryStageId = stageMatch[1];
|
|
748
|
+
recoveryArtifacts = [...new Set([...recoveryArtifacts, ...(serializedDetails.match(/(?:artifact:|\.kxm[\\/]assets[\\/])[A-Za-z0-9_./\\:-]{1,240}/g) ?? [])])].slice(0, 16);
|
|
749
|
+
persistRecoveryContext();
|
|
750
|
+
if (!resultEvent.isError) return;
|
|
751
|
+
const diagnostic = classifyFailure({
|
|
752
|
+
...(resultEvent.toolName ? { toolName: resultEvent.toolName } : {}),
|
|
753
|
+
...((resultEvent.error ?? resultEvent.text) ? { message: resultEvent.error ?? resultEvent.text } : {}),
|
|
754
|
+
...(resultEvent.code ? { code: resultEvent.code } : {}),
|
|
755
|
+
...(resultEvent.statusCode !== undefined ? { statusCode: resultEvent.statusCode } : {}),
|
|
756
|
+
});
|
|
757
|
+
try {
|
|
758
|
+
await client.recordWorkflowEntry(activeRunId, {
|
|
759
|
+
category: "error",
|
|
760
|
+
area: areaForTool(resultEvent.toolName),
|
|
761
|
+
severity: "error",
|
|
762
|
+
summary: diagnosticSummary(diagnostic),
|
|
763
|
+
evidence: diagnosticEvidence(diagnostic, resultEvent.toolCallId),
|
|
764
|
+
});
|
|
765
|
+
} catch {
|
|
766
|
+
// A workflow may already be terminal or the hub may be reconnecting; agent execution should continue.
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
pi.on("agent_settled", async () => {
|
|
771
|
+
if (!activeInbound || !client || settlementInProgress) return;
|
|
772
|
+
if (activeFailure) {
|
|
773
|
+
persistRecoveryContext();
|
|
774
|
+
if (!activeFailureRecorded) {
|
|
775
|
+
try {
|
|
776
|
+
const activeRunId = workflowRunIdForMessage(activeInbound);
|
|
777
|
+
if (activeRunId) {
|
|
778
|
+
await client.recordWorkflowEntry(activeRunId, {
|
|
779
|
+
category: "error",
|
|
780
|
+
area: "harness",
|
|
781
|
+
severity: "error",
|
|
782
|
+
summary: diagnosticSummary(activeFailure),
|
|
783
|
+
evidence: diagnosticEvidence(activeFailure),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
activeFailureRecorded = true;
|
|
787
|
+
} catch {
|
|
788
|
+
// Preserve the message and retry metadata journaling after recovery.
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
notify?.(
|
|
792
|
+
`kxm retained ${activeInbound.id} after ${activeFailure.class}; switch the model or let the supervised worker recover it`,
|
|
793
|
+
"error",
|
|
794
|
+
);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (!activeTurnSettled) {
|
|
798
|
+
settlementReply = boundedPeerReply(
|
|
799
|
+
activeReply ?? "The peer agent completed without a textual response.",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
activeTurnSettled = true;
|
|
803
|
+
await settleActiveInbound(activeInbound.id);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
pi.on("session_shutdown", async () => {
|
|
807
|
+
shuttingDown = true;
|
|
808
|
+
clearActivationWatchdog();
|
|
809
|
+
persistRecoveryContext();
|
|
810
|
+
if (activationRetryTimer) clearTimeout(activationRetryTimer);
|
|
811
|
+
activationRetryTimer = undefined;
|
|
812
|
+
activationRetryAttempt = 0;
|
|
813
|
+
await activationPromise?.catch(() => undefined);
|
|
814
|
+
if (settlementRetryTimer) clearTimeout(settlementRetryTimer);
|
|
815
|
+
settlementRetryTimer = undefined;
|
|
816
|
+
await client?.stop();
|
|
817
|
+
client = undefined;
|
|
818
|
+
notify = undefined;
|
|
819
|
+
requestWorkerRestart = undefined;
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
823
|
+
await applySessionChrome(ctx, { reason: "turn_end" }, false);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
for (const cmd of AGENT_COMMANDS) {
|
|
827
|
+
pi.registerTool({
|
|
828
|
+
name: cmd.name,
|
|
829
|
+
label: cmd.label,
|
|
830
|
+
description: cmd.description,
|
|
831
|
+
parameters: cmd.parameters as unknown as Record<string, unknown>,
|
|
832
|
+
async execute(_toolCallId, params, signal) {
|
|
833
|
+
const policy = enforceToolPolicy(cmd.name);
|
|
834
|
+
if (!policy.allowed) {
|
|
835
|
+
throw new Error(`tool_policy_denied: ${policy.detail ?? policy.error}`);
|
|
836
|
+
}
|
|
837
|
+
return result(
|
|
838
|
+
await workflowCall(() =>
|
|
839
|
+
cmd.execute(requireClient(), (params ?? {}) as Record<string, unknown>, { signal }),
|
|
840
|
+
),
|
|
841
|
+
);
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
pi.registerCommand("kxm", {
|
|
847
|
+
description: "Hub session brief, status line, and hub view",
|
|
848
|
+
getArgumentCompletions: (prefix: string) => {
|
|
849
|
+
const items = kxmSlashCompletions(prefix);
|
|
850
|
+
return items.length > 0 ? items : null;
|
|
851
|
+
},
|
|
852
|
+
handler: async (args, ctx) => {
|
|
853
|
+
const command = parseKxmSlashArgs(typeof args === "string" ? args : undefined);
|
|
854
|
+
if (command === "hub") {
|
|
855
|
+
await showKxmHub(ctx);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (command === "status") {
|
|
859
|
+
const cwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
860
|
+
const hub: SessionHubStatus | undefined = client?.agent
|
|
861
|
+
? { state: "on", evidence: "process", online: true }
|
|
862
|
+
: undefined;
|
|
863
|
+
const brief = await loadSessionBriefAsync(cwd, process.env, currentWork, hub);
|
|
864
|
+
ctx.ui.setStatus?.("kxm", brief.statusLine);
|
|
865
|
+
ctx.ui.setWidget?.("kxm-work", brief.widgetLines);
|
|
866
|
+
ctx.ui.notify(brief.statusLine, "info");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (command === "help") {
|
|
870
|
+
ctx.ui.notify("kxm: /kxm | /kxm status | /kxm hub | /kxm memory | /kxm help. CLI: kxm session brief, kxm hub view, kxm memory brief", "info");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (command === "memory") {
|
|
874
|
+
const cwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
875
|
+
const kxmBin = process.env.KXM_BIN || "kxm";
|
|
876
|
+
const res = spawnSync(kxmBin, ["memory", "brief"], { cwd, encoding: "utf8", windowsHide: true });
|
|
877
|
+
let memText = res.status === 0 && res.stdout ? res.stdout.trim() : "";
|
|
878
|
+
if (!memText) {
|
|
879
|
+
const script = join(cwd, "scripts", "kxm.mjs");
|
|
880
|
+
if (existsSync(script)) {
|
|
881
|
+
const scriptRes = spawnSync(process.execPath, [script, "memory", "brief"], { cwd, encoding: "utf8", windowsHide: true });
|
|
882
|
+
if (scriptRes.status === 0 && scriptRes.stdout) memText = scriptRes.stdout.trim();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
ctx.ui.notify(memText || "No active project memory facts.", "info");
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
await applySessionChrome(ctx, { reason: "new" }, command === "brief");
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return nousFactoryWork(pi, (report) => {
|
|
893
|
+
nousReport = report;
|
|
894
|
+
});
|
|
895
|
+
}
|