@humwick/runner 0.0.0-stage → 0.130.1
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/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +42 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { isHumanAgentId, errMessage, type Message, type HumwickBusClient, type HumwickHttpClient } from "@humwick/sdk";
|
|
2
|
+
import type { ProviderAdapter, ProviderConfig, ProviderDeliverContext, ProviderSessionEvent, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderUserPromptInput } from "../adapter";
|
|
3
|
+
import { publishCapturedResponse } from "../response-capture-report";
|
|
4
|
+
import { submittedResponseReplyRoute, type responseCaptureReplyRoute } from "../reply-obligation-cache";
|
|
5
|
+
import { PRE_DESTROY_TIMEOUT_MS, reasonExitsRunner, type LifecycleAction, type SessionDestroyReason } from "../session-destroy";
|
|
6
|
+
import type { SessionMirror } from "./session-mirror";
|
|
7
|
+
import { TurnOpenerLedger } from "../turn-opener";
|
|
8
|
+
|
|
9
|
+
type CaptureOptions = { transcriptPath?: string };
|
|
10
|
+
interface TurnReportDeps {
|
|
11
|
+
agentId: string;
|
|
12
|
+
provider: string;
|
|
13
|
+
adapter: Pick<ProviderAdapter, "captureSessionTurn" | "handleUserPrompt">;
|
|
14
|
+
providerConfig: Pick<ProviderConfig, "chatCaptureMode" | "reasoningCapture">;
|
|
15
|
+
http: Pick<HumwickHttpClient, "getThread">;
|
|
16
|
+
currentTurnId(): string | undefined;
|
|
17
|
+
currentTurnStartedAt(): number | undefined;
|
|
18
|
+
setTurnId(id: string): void;
|
|
19
|
+
setTurnStartedAt(at: number): void;
|
|
20
|
+
lastTranscriptPath(): string | undefined;
|
|
21
|
+
noteTranscriptPath(path: string): void;
|
|
22
|
+
providerSessionId(): string;
|
|
23
|
+
queuedTurnFollows(): boolean;
|
|
24
|
+
setQueuedTurnFollows(queued: boolean): void;
|
|
25
|
+
settlePollIntervalMs(): number;
|
|
26
|
+
settlePollTimeoutMs(): number;
|
|
27
|
+
beginTurnCapture(): void;
|
|
28
|
+
endTurnCapture(): void;
|
|
29
|
+
flushSessionEvents(timeoutMs: number): Promise<boolean>;
|
|
30
|
+
pendingSessionEventCount(): number;
|
|
31
|
+
publishSessionEvent: SessionMirror["publishSessionEvent"];
|
|
32
|
+
reportOutbox: Parameters<typeof publishCapturedResponse>[0]["outbox"];
|
|
33
|
+
flushOutbox(timeoutMs: number): Promise<boolean>;
|
|
34
|
+
settleTerminalTokenUsage(path: string | undefined): Promise<void>;
|
|
35
|
+
noteOutput(): void;
|
|
36
|
+
notePrompt(text: string, firedAt: number | undefined): void;
|
|
37
|
+
isRunnerInjectedPrompt(text: string): boolean;
|
|
38
|
+
deliverySubmitHold(): boolean;
|
|
39
|
+
tryRecoverProviderSubmitHold(started: boolean): void;
|
|
40
|
+
lifecycleAction(): LifecycleAction | undefined;
|
|
41
|
+
isStopped(): boolean;
|
|
42
|
+
terminalStatusLatched(): boolean;
|
|
43
|
+
setLifecycleAction(action: LifecycleAction | undefined): void;
|
|
44
|
+
noteFinalizing(action: LifecycleAction, at: number): void;
|
|
45
|
+
publishRunnerStatus(update: Parameters<HumwickBusClient["status"]>[0]): boolean;
|
|
46
|
+
publishStatus(): void;
|
|
47
|
+
captureContextRatio(reason: SessionDestroyReason, opts?: CaptureOptions): Promise<void>;
|
|
48
|
+
captureContinuationArchive(reason: SessionDestroyReason, opts?: CaptureOptions): Promise<void>;
|
|
49
|
+
responseCaptureReplyRoute(startedAt: number | undefined, suppressUser: boolean): ReturnType<typeof responseCaptureReplyRoute>;
|
|
50
|
+
dirtyObligations(): void;
|
|
51
|
+
sessionLog(message: string): void;
|
|
52
|
+
sessionDebug(message: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Bounded window to deliver the durable outbox before an exit-bound teardown (#183).
|
|
56
|
+
const OUTBOX_FLUSH_TIMEOUT_MS = 3_000;
|
|
57
|
+
|
|
58
|
+
export class TurnReport {
|
|
59
|
+
private pendingPromptMessageId?: number;
|
|
60
|
+
private readonly turnOpener = new TurnOpenerLedger(() => this.deps.agentId);
|
|
61
|
+
// Coalesces every pre-session-destroy report for one provider incarnation, including a
|
|
62
|
+
// sequential SessionEnd hook that arrives after the shutdown command's capture completed.
|
|
63
|
+
private preDestroyRun?: { providerSessionId: string; capture: Promise<void>; exitFlush?: Promise<void> };
|
|
64
|
+
|
|
65
|
+
constructor(private readonly deps: TurnReportDeps) {}
|
|
66
|
+
|
|
67
|
+
notePendingPrompt(messageId: number): void { this.pendingPromptMessageId = messageId; }
|
|
68
|
+
takePendingPrompt(): number | undefined {
|
|
69
|
+
const messageId = this.pendingPromptMessageId;
|
|
70
|
+
this.pendingPromptMessageId = undefined;
|
|
71
|
+
return messageId;
|
|
72
|
+
}
|
|
73
|
+
clearPreDestroyRun(): void { this.preDestroyRun = undefined; }
|
|
74
|
+
armTurnOpener(messages: Message[]): void { this.turnOpener.arm(messages); }
|
|
75
|
+
openTurnOpener(messages: Message[], opensTurn: boolean, ctx?: ProviderDeliverContext): ReturnType<TurnOpenerLedger["open"]> { return this.turnOpener.open(messages, opensTurn, ctx); }
|
|
76
|
+
noteTurnStarted(turnId: string): void { this.turnOpener.noteTurnStarted(turnId); }
|
|
77
|
+
takeTurnOpener(turnId: string | undefined): ReturnType<TurnOpenerLedger["take"]> { return this.turnOpener.take(turnId); }
|
|
78
|
+
|
|
79
|
+
async publishProviderResponse(event: ProviderSessionEvent, body: string, turnId: string | undefined): Promise<void> {
|
|
80
|
+
// Dashboard prompt injection is already answered by this captured App Server
|
|
81
|
+
// response. User obligations still belong to the session mirror; spawner
|
|
82
|
+
// obligations (#413) route to the spawn-parent with replyTo to clear the debt.
|
|
83
|
+
// #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
|
|
84
|
+
// autonomous provider turn with no pending prompt and no eligible obligation used to
|
|
85
|
+
// hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
|
|
86
|
+
// so it was neither shown in the dashboard nor eligible for report-up. The route only
|
|
87
|
+
// decides whether an explicit reply/obligation-clear also happens; when there's none,
|
|
88
|
+
// replyToMessageId stays undefined and the capture still lands as a plain session-mirror
|
|
89
|
+
// response (enqueueCapturedResponseReport's own `!replyTo` gate then correctly skips
|
|
90
|
+
// report-up — there's no spawner obligation to report up against).
|
|
91
|
+
let replyToMessageId: number | undefined, replyTarget = "user";
|
|
92
|
+
// #2335 — the route resolved to a channel-egress bridge; publishCapturedResponse re-addresses
|
|
93
|
+
// the capture to the dashboard mirror rather than DM'ing this agent's narration to a human.
|
|
94
|
+
let channelTarget = false, answersMessageId: number | undefined; // #2840 — the delivered opener this final answers
|
|
95
|
+
// A hookless capture finishes after its idle edge, so its opener was snapshotted there (event.opener).
|
|
96
|
+
const pendingPrompt = this.pendingPromptMessageId, opener = event.opener !== undefined ? event.opener ?? undefined : this.takeTurnOpener(turnId);
|
|
97
|
+
if (event.replyRoute !== undefined) {
|
|
98
|
+
this.takePendingPrompt();
|
|
99
|
+
const route = await submittedResponseReplyRoute(event.replyRoute, id => this.deps.http.getThread(id), this.deps.agentId);
|
|
100
|
+
if (route) { replyToMessageId = answersMessageId = route.replyToMessageId; replyTarget = route.to; channelTarget = route.channelTarget === true; }
|
|
101
|
+
} else if (pendingPrompt && !this.deps.queuedTurnFollows()) { // #2919 — a prompt queued behind this turn is answered by its own turn
|
|
102
|
+
replyToMessageId = pendingPrompt;
|
|
103
|
+
this.takePendingPrompt();
|
|
104
|
+
} else {
|
|
105
|
+
// An opener already answered in its thread stays mirror-only; an obligation route (a possibly stale
|
|
106
|
+
// snapshot) passes the same thread check, so a native final never doubles an explicit reply.
|
|
107
|
+
const candidate = opener ?? this.deps.responseCaptureReplyRoute(this.deps.currentTurnStartedAt(), event.replyToUserObligation !== true);
|
|
108
|
+
const route = candidate?.replyToMessageId && !isHumanAgentId(candidate.to) ? await submittedResponseReplyRoute({ ...candidate, replyToMessageId: candidate.replyToMessageId }, id => this.deps.http.getThread(id), this.deps.agentId, !opener) : candidate;
|
|
109
|
+
answersMessageId = opener ? route?.replyToMessageId : undefined;
|
|
110
|
+
if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; channelTarget = route.channelTarget === true; }
|
|
111
|
+
}
|
|
112
|
+
await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.deps.publishSessionEvent({ ...sessionEvent, ...(event.occurredAt ? { occurredAt: event.occurredAt } : {}) }), outbox: this.deps.reportOutbox, provider: this.deps.provider, from: this.deps.agentId, to: replyTarget, body, replyTo: replyToMessageId, channelTarget, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}), ...(answersMessageId ? { answersMessageId } : {}) } });
|
|
113
|
+
if (replyToMessageId) this.deps.dirtyObligations();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async publishSessionTurn(input: ProviderSessionTurnInput): Promise<{ reasoningSettled: boolean } | void> {
|
|
117
|
+
if (input.transcriptPath) this.deps.noteTranscriptPath(input.transcriptPath);
|
|
118
|
+
// #2389 — a pre-flush capture IS a tool call in flight: independent proof the turn is doing
|
|
119
|
+
// work, and the only such proof for a tools-only turn on an agent with reasoningCapture off.
|
|
120
|
+
if (input.isPreFlush) this.deps.noteOutput();
|
|
121
|
+
this.deps.beginTurnCapture();
|
|
122
|
+
if (!input.isPreFlush) this.deps.setQueuedTurnFollows(false);
|
|
123
|
+
try {
|
|
124
|
+
const ctx = input.isPreFlush ? this.sessionTurnContext() : { ...this.sessionTurnContext(), noteQueuedTurn: () => { this.deps.setQueuedTurnFollows(true); } };
|
|
125
|
+
const result = await this.deps.adapter.captureSessionTurn?.(input, ctx);
|
|
126
|
+
return typeof result?.reasoningSettled === "boolean" ? { reasoningSettled: result.reasoningSettled } : undefined;
|
|
127
|
+
} finally {
|
|
128
|
+
this.deps.endTurnCapture();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
sessionTurnContext(): ProviderSessionTurnContext {
|
|
133
|
+
return {
|
|
134
|
+
turnId: this.deps.currentTurnId(),
|
|
135
|
+
turnStartedAt: this.deps.currentTurnStartedAt(),
|
|
136
|
+
chatCaptureMode: this.deps.providerConfig.chatCaptureMode,
|
|
137
|
+
reasoningCapture: this.deps.providerConfig.reasoningCapture,
|
|
138
|
+
settlePollIntervalMs: this.deps.settlePollIntervalMs(),
|
|
139
|
+
settlePollTimeoutMs: this.deps.settlePollTimeoutMs(),
|
|
140
|
+
flushSessionEvents: (timeoutMs) => this.deps.flushSessionEvents(timeoutMs),
|
|
141
|
+
pendingSessionEventCount: () => this.deps.pendingSessionEventCount(),
|
|
142
|
+
log: (message) => this.deps.sessionLog(message),
|
|
143
|
+
debug: (message) => this.deps.sessionDebug(message),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async handleUserPrompt(input: ProviderUserPromptInput): Promise<void> {
|
|
148
|
+
if (input.transcriptPath) this.deps.noteTranscriptPath(input.transcriptPath);
|
|
149
|
+
if (!this.deps.currentTurnId()) {
|
|
150
|
+
const turnId = input.promptId ?? crypto.randomUUID();
|
|
151
|
+
this.deps.setTurnId(turnId);
|
|
152
|
+
this.turnOpener.noteTurnStarted(turnId);
|
|
153
|
+
this.deps.setTurnStartedAt(Date.now());
|
|
154
|
+
}
|
|
155
|
+
const text = input.prompt.trim();
|
|
156
|
+
this.deps.notePrompt(text, input.firedAt);
|
|
157
|
+
if (text && !this.deps.isRunnerInjectedPrompt(text)) {
|
|
158
|
+
this.deps.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
|
|
159
|
+
await this.deps.publishSessionEvent({
|
|
160
|
+
from: "user",
|
|
161
|
+
to: this.deps.agentId,
|
|
162
|
+
body: text,
|
|
163
|
+
session: { type: "prompt", origin: "terminal", turnId: this.deps.currentTurnId() },
|
|
164
|
+
});
|
|
165
|
+
} else if (text) {
|
|
166
|
+
this.deps.sessionDebug("user-prompt hook: skipped echo (runner-injected)");
|
|
167
|
+
}
|
|
168
|
+
await this.deps.adapter.handleUserPrompt?.(input, this.sessionTurnContext());
|
|
169
|
+
// #2664 — a late UserPromptSubmit hook is what releases an unconfirmed Claude submit.
|
|
170
|
+
if (this.deps.deliverySubmitHold()) this.deps.tryRecoverProviderSubmitHold(false);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A provider lifecycle hook reported a session boundary
|
|
174
|
+
// → control server). Normalize the raw provider reason to a SessionDestroyReason and run
|
|
175
|
+
// the same pre-destroy seam the bus commands use. `clear`/`compact` continue the session;
|
|
176
|
+
// anything else (logout, prompt_input_exit, other) is a real termination.
|
|
177
|
+
async handleSessionBoundary(input: { reason?: string; transcriptPath?: string }): Promise<void> {
|
|
178
|
+
// Reason mapping is fail-safe-toward-termination: only the two known session-
|
|
179
|
+
// CONTINUING reasons are special-cased; everything else (logout, prompt_input_exit,
|
|
180
|
+
// other, AND any future reason) maps to "shutdown" → full pre-destroy capture.
|
|
181
|
+
// If a provider adds a new benign/continuing boundary reason, add it here. Until
|
|
182
|
+
// then it will trigger a harmless but wasteful full context capture on a session
|
|
183
|
+
// that isn't actually ending.
|
|
184
|
+
const reason = input.reason === "compact" ? "compact"
|
|
185
|
+
: input.reason === "clear" ? "clear"
|
|
186
|
+
: "shutdown";
|
|
187
|
+
await this.runPreSessionDestroy(reason, { transcriptPath: input.transcriptPath });
|
|
188
|
+
// clear/compact CONTINUE the session — the finalizing window is transient. The bus-command
|
|
189
|
+
// path (handleCommand) restores the addressable status in its finally; the hook path has no
|
|
190
|
+
// such teardown, so without this the dashboard stays stuck on "wrapping up — messaging
|
|
191
|
+
// paused" with the composer disabled forever. Only restore if we're still in the exact
|
|
192
|
+
// finalizing state we published — a concurrent bus command (kill/restart/shutdown) may have
|
|
193
|
+
// transitioned lifecycleAction since, and that must win. "shutdown" reasons end the session,
|
|
194
|
+
// so their finalizing state is superseded by going offline; leave it be.
|
|
195
|
+
if ((reason === "clear" || reason === "compact") && this.deps.lifecycleAction() === `finalizing-${reason}` && !this.deps.isStopped()) {
|
|
196
|
+
this.deps.setLifecycleAction(undefined);
|
|
197
|
+
this.deps.publishStatus();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The pre-session-destroy seam (#183): the single place end-of-session work runs before
|
|
202
|
+
// an invasive transition (compact/clear/restart/shutdown/kill). Best-effort and
|
|
203
|
+
// time-boxed so it never hangs teardown; concurrent calls for the same teardown coalesce
|
|
204
|
+
// (a shutdown bus command and the SessionEnd hook can both fire). During the window the
|
|
205
|
+
// agent is published non-addressable so the operator sees "wrapping up", not "busy".
|
|
206
|
+
runPreSessionDestroy(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
|
|
207
|
+
const providerSessionId = this.deps.providerSessionId();
|
|
208
|
+
let record = this.preDestroyRun?.providerSessionId === providerSessionId ? this.preDestroyRun : undefined;
|
|
209
|
+
if (!record) {
|
|
210
|
+
const capture = (async () => {
|
|
211
|
+
try {
|
|
212
|
+
this.publishFinalizing(reason);
|
|
213
|
+
await Promise.race([
|
|
214
|
+
Promise.all([
|
|
215
|
+
this.deps.captureContextRatio(reason, opts),
|
|
216
|
+
this.deps.captureContinuationArchive(reason, opts),
|
|
217
|
+
// #2491 review MED-2 — prefer the already-tracked lastTranscriptPath (what the
|
|
218
|
+
// capture instance's byte-offset watermark is actually keyed to) over the hook-
|
|
219
|
+
// supplied opts.transcriptPath: a mismatch resets the watermark to 0 and re-reads
|
|
220
|
+
// + re-posts the whole transcript serially (safe — dedupe-keyed — but wasteful).
|
|
221
|
+
// Fall back to the hook-supplied path only when no capture has tracked one yet.
|
|
222
|
+
this.deps.settleTerminalTokenUsage(this.deps.lastTranscriptPath() ?? opts?.transcriptPath),
|
|
223
|
+
]).then(() => undefined),
|
|
224
|
+
new Promise<void>((resolve) => setTimeout(resolve, PRE_DESTROY_TIMEOUT_MS)),
|
|
225
|
+
]);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.deps.sessionLog(`pre-destroy capture failed: ${errMessage(error)}`);
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
const createdRecord: NonNullable<typeof this.preDestroyRun> = this.preDestroyRun = { providerSessionId, capture };
|
|
231
|
+
record = createdRecord;
|
|
232
|
+
// Compact/clear continue this incarnation and delimit a fresh capture segment. Restart
|
|
233
|
+
// retains the capture until spawnProvider changes incarnation so a later SessionEnd hook
|
|
234
|
+
// coalesces instead of repeating the full transcript/archive pass.
|
|
235
|
+
if (reason === "compact" || reason === "clear") {
|
|
236
|
+
void capture.finally(() => { if (this.preDestroyRun === createdRecord && !createdRecord.exitFlush) this.preDestroyRun = undefined; });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!reasonExitsRunner(reason)) return record.capture;
|
|
240
|
+
// Exit flushing is a separate once-per-incarnation obligation from capture. A shutdown hook
|
|
241
|
+
// coalesced onto a retained restart capture must still flush both durable outboxes (#1139),
|
|
242
|
+
// without repeating the expensive capture itself.
|
|
243
|
+
record.exitFlush ??= record.capture.then(async () => {
|
|
244
|
+
try {
|
|
245
|
+
const [delivered, sessionDelivered] = await Promise.all([this.deps.flushOutbox(OUTBOX_FLUSH_TIMEOUT_MS), this.deps.flushSessionEvents(OUTBOX_FLUSH_TIMEOUT_MS)]);
|
|
246
|
+
if (!delivered || !sessionDelivered) this.deps.sessionLog(`teardown outbox flush incomplete before ${reason}`);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.deps.sessionLog(`teardown outbox flush failed: ${errMessage(error)}`);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return record.exitFlush;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Publish the transient pre-destroy state: a non-offline status with ready:false (so the
|
|
255
|
+
// agent drops out of isAgentOnline fan-out targeting without going "offline") plus a
|
|
256
|
+
// finalizing-<reason> lifecycleAction the dashboard renders as "wrapping up" with the
|
|
257
|
+
// composer disabled.
|
|
258
|
+
private publishFinalizing(reason: SessionDestroyReason): void {
|
|
259
|
+
// A SessionEnd hook may arrive while runner-owned teardown is already in flight. It still
|
|
260
|
+
// participates in the coalesced capture above, but cannot replace the runner's lifecycle pill.
|
|
261
|
+
if (this.deps.terminalStatusLatched()) return;
|
|
262
|
+
const lifecycleAction = `finalizing-${reason}` as const;
|
|
263
|
+
const lifecycleActionAt = Date.now();
|
|
264
|
+
if (this.deps.publishRunnerStatus({ agentStatus: "busy", ready: false, meta: { lifecycleAction, lifecycleActionAt } })) {
|
|
265
|
+
this.deps.noteFinalizing(lifecycleAction, lifecycleActionAt);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { relayEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { RELAY_TOKEN_HEADER, resendOnRotatedToken, type ApiBudgetId, type ApiBudgetDecision, type ApiBudgetReservation, type ApiBudgetTokenUsage } from "@humwick/sdk";
|
|
3
|
+
import type { RunnerSpawnConfig } from "./adapter";
|
|
4
|
+
import { ProviderSubmitFailure } from "./provider-submit-failure";
|
|
5
|
+
|
|
6
|
+
/** One reservation/report/retry/settlement path for every metered adapter. */
|
|
7
|
+
export class ApiBudgetAccounting {
|
|
8
|
+
reservation?: ApiBudgetReservation;
|
|
9
|
+
costMicroUsd = 0;
|
|
10
|
+
pending: Promise<void> = Promise.resolve();
|
|
11
|
+
settlement?: Promise<void>;
|
|
12
|
+
private readonly usage = new Map<string, ApiBudgetTokenUsage>();
|
|
13
|
+
|
|
14
|
+
constructor(readonly budgetId: ApiBudgetId,
|
|
15
|
+
private readonly request: (action: "reserve" | "report", body: Record<string, unknown>) => Promise<ApiBudgetDecision>,
|
|
16
|
+
private readonly adopted: (reservation: ApiBudgetReservation) => void = () => {}) {}
|
|
17
|
+
|
|
18
|
+
async reserve(sessionId: string, endedSession?: {sessionId: string; costMicroUsd: number}): Promise<ApiBudgetDecision> {
|
|
19
|
+
const decision = await this.request("reserve", { sessionId, ...(endedSession ? {endedSession} : {}) });
|
|
20
|
+
if (!decision.allowed || !decision.reservation) throw new ProviderSubmitFailure(`API admission refused: ${decision.reason ?? "invalid reservation"}`, false);
|
|
21
|
+
this.reservation = decision.reservation;
|
|
22
|
+
this.costMicroUsd = 0;
|
|
23
|
+
this.usage.clear();
|
|
24
|
+
this.pending = Promise.resolve();
|
|
25
|
+
this.settlement = undefined;
|
|
26
|
+
return decision;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
observe(record: ApiBudgetTokenUsage): void {
|
|
30
|
+
const prior = this.usage.get(record.dedupeKey);
|
|
31
|
+
if (!prior || record.createdAt >= prior.createdAt) this.usage.set(record.dedupeKey, record);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async report(body: Record<string, unknown> = {}): Promise<ApiBudgetDecision> {
|
|
35
|
+
if (!this.reservation) throw new ProviderSubmitFailure("API reservation unavailable", false);
|
|
36
|
+
const records = [...this.usage.values()];
|
|
37
|
+
let decision!: ApiBudgetDecision;
|
|
38
|
+
for (let offset = 0; offset === 0 || offset < records.length; offset += 200) {
|
|
39
|
+
const batch = records.slice(offset, offset + 200);
|
|
40
|
+
decision = await this.request("report", { sessionId: this.reservation.sessionId,
|
|
41
|
+
...(this.budgetId === "openai-api" ? { usage: batch } : { costMicroUsd: this.costMicroUsd }), ...body,
|
|
42
|
+
...(body.settle && offset + 200 < records.length ? { settle: false } : {}) });
|
|
43
|
+
for (const record of batch) if (this.usage.get(record.dedupeKey) === record) this.usage.delete(record.dedupeKey);
|
|
44
|
+
if (decision.reservation?.sessionId === this.reservation.sessionId) {
|
|
45
|
+
this.reservation = decision.reservation;
|
|
46
|
+
this.costMicroUsd = Math.max(this.costMicroUsd, decision.reservation.costMicroUsd);
|
|
47
|
+
this.adopted(decision.reservation);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return decision;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
queue(report: () => Promise<void>, failed: (error: unknown) => void): void {
|
|
54
|
+
this.pending = this.pending.then(report).catch(failed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async settle(): Promise<void> {
|
|
58
|
+
await this.pending;
|
|
59
|
+
if (!this.reservation) return;
|
|
60
|
+
this.settlement ??= this.report({ settle: true }).then(() => {});
|
|
61
|
+
await this.settlement;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async transport(config: RunnerSpawnConfig, action: "reserve" | "report", body: Record<string, unknown>): Promise<ApiBudgetDecision> {
|
|
65
|
+
let status: number | undefined;
|
|
66
|
+
try {
|
|
67
|
+
const response = await resendOnRotatedToken(() => config.getRelayToken?.() ?? relayEnv(config.env, "AGENT_RELAY_TOKEN"), (token) => fetch(`${config.relayUrl}/api/agents/${encodeURIComponent(config.agentId)}/api-budget/${action}`, {
|
|
68
|
+
method: "POST", headers: { "content-type": "application/json", ...(token ? { [RELAY_TOKEN_HEADER]: token } : {}) },
|
|
69
|
+
body: JSON.stringify(this.budgetId === "claude-api" ? body : { ...body, budgetId: this.budgetId }), signal: AbortSignal.timeout(3_000),
|
|
70
|
+
})).catch(error => { throw new ProviderSubmitFailure("API budget transport unavailable", true, error); });
|
|
71
|
+
status = response.status;
|
|
72
|
+
if (!response.ok) throw new ProviderSubmitFailure(`API budget RPC failed (${status})`, status >= 500 || status === 429);
|
|
73
|
+
const result = await response.json().catch(error => {
|
|
74
|
+
throw new ProviderSubmitFailure("API budget response unreadable", !(error instanceof SyntaxError), error);
|
|
75
|
+
}) as ApiBudgetDecision;
|
|
76
|
+
if (!result || typeof result.allowed !== "boolean") throw new ProviderSubmitFailure("Invalid API budget decision", false);
|
|
77
|
+
return result;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Never log response bodies, URLs, tokens or arbitrary transport error messages.
|
|
80
|
+
const cause = error instanceof ProviderSubmitFailure ? error.cause : error;
|
|
81
|
+
const name = cause instanceof Error && ["TimeoutError", "AbortError", "TypeError", "SyntaxError"].includes(cause.name) ? cause.name : "unknown";
|
|
82
|
+
console.error(`[api budget] ${JSON.stringify({ action, status: status ?? null, cause: name,
|
|
83
|
+
failure: error instanceof ProviderSubmitFailure ? error.message : "Invalid budget response" })}`);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isTransientBudgetFailure(error: unknown): boolean {
|
|
90
|
+
return error instanceof ProviderSubmitFailure && error.retrySafe;
|
|
91
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// #3411 — the runner's half of on-touch area memory (epic #3406). Every file a normalized tool event
|
|
2
|
+
// names is matched to the repo's path areas (`.agent-relay/areas.json`); an area's first touch in a
|
|
3
|
+
// context generation is reported to POST /api/agents/:id/area-entered (#3410), which selects the
|
|
4
|
+
// memories. Claude returns them through PostToolUse; other providers use `agent.injectContext`.
|
|
5
|
+
|
|
6
|
+
import { relative, sep } from "node:path";
|
|
7
|
+
import { errMessage, PROJECT_FETCH_TIMEOUT_MS, type AreaEnteredInput, type AreaEnteredResult, type ProjectGit, type HumwickHttpClient } from "@humwick/sdk";
|
|
8
|
+
import { AREAS_FILE, compileAreaMatcher, readAreasAtDefaultBranch, type AreaDefinition } from "@humwick/sdk/areas";
|
|
9
|
+
import type { ProviderAdapter, ProviderSessionEvent } from "./adapter";
|
|
10
|
+
import type { ControlServerOptions } from "./control-server";
|
|
11
|
+
|
|
12
|
+
/** The `agent.injectContext` reason the server gives an area block. */
|
|
13
|
+
export const AREA_MEMORY_REASON = "memory-area";
|
|
14
|
+
/** The server refuses a longer `exclude` (AREA_EXCLUDE_MAX in src/memory-area-injection.ts). */
|
|
15
|
+
const AREA_EXCLUDE_MAX = 200;
|
|
16
|
+
/** One report plus two retries per area per generation. */
|
|
17
|
+
const AREA_REPORT_ATTEMPTS = 3;
|
|
18
|
+
/** Paths touched while the areas load; past this, later ones wait for the next touch. */
|
|
19
|
+
const MAX_PENDING_PATHS = 200;
|
|
20
|
+
const GIT_READ_TIMEOUT_MS = 5_000;
|
|
21
|
+
|
|
22
|
+
export interface AreaMatcher {
|
|
23
|
+
repoRoot: string;
|
|
24
|
+
match(absPath: string): string[];
|
|
25
|
+
definitions?: AreaDefinition[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AreasLookup = { matcher: AreaMatcher } | { off: string };
|
|
29
|
+
|
|
30
|
+
/** Bounded git in the agent's checkout, for the SDK's default-branch reader. */
|
|
31
|
+
function checkoutGit(cwd: string): ProjectGit {
|
|
32
|
+
return async (args, options) => {
|
|
33
|
+
const proc = Bun.spawn({
|
|
34
|
+
cmd: ["git", "-C", cwd, ...args],
|
|
35
|
+
stdin: options?.input === undefined ? "ignore" : new TextEncoder().encode(options.input),
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
timeout: options?.network ? PROJECT_FETCH_TIMEOUT_MS : GIT_READ_TIMEOUT_MS,
|
|
39
|
+
killSignal: "SIGKILL",
|
|
40
|
+
});
|
|
41
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
42
|
+
return { ok: proc.exitCode === 0, stdout, stderr, ...(proc.signalCode ? { unavailable: true } : {}) };
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Never throws: the areas.json on the checkout's default branch, read through the same SDK reader
|
|
47
|
+
* the relay uses (#3635), so a worktree older than the file still tracks its areas. */
|
|
48
|
+
export async function loadAreas(cwd: string): Promise<AreasLookup> {
|
|
49
|
+
try {
|
|
50
|
+
const git = checkoutGit(cwd);
|
|
51
|
+
const top = await git(["rev-parse", "--show-toplevel"]);
|
|
52
|
+
if (!top.ok || !top.stdout.trim()) return { off: "not a git checkout" };
|
|
53
|
+
const read = await readAreasAtDefaultBranch(git);
|
|
54
|
+
if (read.status === "absent") return { off: `no ${AREAS_FILE} at ${read.ref}` };
|
|
55
|
+
if (read.status === "invalid") return { off: `${AREAS_FILE} at ${read.ref} is invalid: ${read.error}` };
|
|
56
|
+
const repoRoot = top.stdout.trim();
|
|
57
|
+
return { matcher: { repoRoot, definitions: read.config.areas, match: compileAreaMatcher(read.config, repoRoot) } };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { off: `${AREAS_FILE} unreadable: ${errMessage(error)}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AreaMemoryDeps {
|
|
64
|
+
agentId(): string;
|
|
65
|
+
cwd(): string;
|
|
66
|
+
providerSessionId(): string;
|
|
67
|
+
http(): Pick<HumwickHttpClient, "request">;
|
|
68
|
+
log(message: string): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class AreaMemoryTracker {
|
|
72
|
+
private generation = 0;
|
|
73
|
+
private readonly servedAreas = new Set<string>();
|
|
74
|
+
private readonly servedEntryIds = new Set<string>();
|
|
75
|
+
private readonly failedReports = new Map<string, number>();
|
|
76
|
+
/** This generation's areas, read from the default branch (#3635); unset until loaded. */
|
|
77
|
+
private lookup?: AreasLookup;
|
|
78
|
+
/** The generation a load runs for. */
|
|
79
|
+
private loadingFor?: number;
|
|
80
|
+
private pending: string[] = [];
|
|
81
|
+
private loading?: Promise<void>;
|
|
82
|
+
private toolHook = false;
|
|
83
|
+
private hookTail: Promise<unknown> = Promise.resolve();
|
|
84
|
+
|
|
85
|
+
constructor(private readonly deps: AreaMemoryDeps) {}
|
|
86
|
+
|
|
87
|
+
/** Report the first touch of each area these events name. Synchronous; the POSTs run detached. */
|
|
88
|
+
noteToolPaths(events: readonly ProviderSessionEvent[]): void {
|
|
89
|
+
if (this.toolHook) return;
|
|
90
|
+
try {
|
|
91
|
+
for (const event of events) {
|
|
92
|
+
if (event.type !== "tool" || !event.paths?.length) continue;
|
|
93
|
+
for (const path of event.paths) this.touch(path);
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
this.deps.log(`area memory: tool paths not matched: ${errMessage(error)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Claude's hook owns file touches; transcript notifications must not claim them first (#3646). */
|
|
101
|
+
enableToolHook(): void { this.toolHook = true; this.prepare(); }
|
|
102
|
+
|
|
103
|
+
/** Warm the default-branch snapshot while the provider starts, outside the hook budget. */
|
|
104
|
+
prepare(): void { if (!this.lookup) this.queue(); }
|
|
105
|
+
|
|
106
|
+
controlHooks(adapter: Pick<ProviderAdapter, "toolHookPaths">, onHookCheck: NonNullable<ControlServerOptions["onHookCheck"]>): Pick<ControlServerOptions, "onHookCheck" | "onToolUse"> {
|
|
107
|
+
this.prepare();
|
|
108
|
+
return {
|
|
109
|
+
onHookCheck: (input) => { if (adapter.toolHookPaths) this.enableToolHook(); onHookCheck(input); },
|
|
110
|
+
onToolUse: (input, signal) => { const paths = adapter.toolHookPaths?.(input); return paths?.length ? this.enterFromToolHook(paths, signal) : Promise.resolve(""); },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async enterFromToolHook(paths: readonly string[], signal: AbortSignal): Promise<string> {
|
|
115
|
+
const generation = this.generation;
|
|
116
|
+
const aborted = Promise.withResolvers<string>();
|
|
117
|
+
const onAbort = () => aborted.reject(signal.reason);
|
|
118
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
119
|
+
if (signal.aborted) onAbort();
|
|
120
|
+
// Parallel tool results share exclusions, so an overlapping entry appears only once.
|
|
121
|
+
const work = this.hookTail.then(async () => {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
this.prepare();
|
|
124
|
+
await Promise.race([this.loading, aborted.promise]);
|
|
125
|
+
signal.throwIfAborted();
|
|
126
|
+
if (generation !== this.generation || !this.lookup || !("matcher" in this.lookup)) return "";
|
|
127
|
+
const matcher = this.lookup.matcher;
|
|
128
|
+
const blocks: string[] = [];
|
|
129
|
+
const entryIds: string[] = [];
|
|
130
|
+
const claimed: string[] = [];
|
|
131
|
+
let committed = false;
|
|
132
|
+
try {
|
|
133
|
+
for (const path of paths) for (const area of matcher.match(path)) {
|
|
134
|
+
signal.throwIfAborted();
|
|
135
|
+
if (generation !== this.generation) return "";
|
|
136
|
+
if (this.servedAreas.has(area)) continue;
|
|
137
|
+
const definition = matcher.definitions?.find((item) => item.name === area);
|
|
138
|
+
if (!definition) continue;
|
|
139
|
+
this.servedAreas.add(area);
|
|
140
|
+
claimed.push(area);
|
|
141
|
+
try {
|
|
142
|
+
const body = this.body(area, matcher, path, generation);
|
|
143
|
+
const result = await this.report({ ...body, exclude: [...this.servedEntryIds, ...entryIds].slice(-AREA_EXCLUDE_MAX), inline: definition }, signal);
|
|
144
|
+
signal.throwIfAborted();
|
|
145
|
+
if (generation !== this.generation) return "";
|
|
146
|
+
if (result?.content) {
|
|
147
|
+
blocks.push(result.content);
|
|
148
|
+
entryIds.push(...result.entryIds);
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
this.reportFailed(area, generation, error);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const id of entryIds) this.servedEntryIds.add(id);
|
|
156
|
+
committed = true;
|
|
157
|
+
return blocks.join("\n\n");
|
|
158
|
+
} finally {
|
|
159
|
+
if (!committed && generation === this.generation) {
|
|
160
|
+
for (const area of claimed) if ((this.failedReports.get(area) ?? 0) < AREA_REPORT_ATTEMPTS) this.servedAreas.delete(area);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
this.hookTail = work.catch(() => {});
|
|
165
|
+
try { return await Promise.race([work, aborted.promise]); }
|
|
166
|
+
catch (error) {
|
|
167
|
+
this.deps.log(`area memory: PostToolUse did not return context: ${errMessage(error)}`);
|
|
168
|
+
return "";
|
|
169
|
+
} finally { signal.removeEventListener("abort", onAbort); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True (and logged) for a `memory-area` inject queued for an earlier generation: a reset happened in flight. */
|
|
173
|
+
isStaleInjection(params: Record<string, unknown>): boolean {
|
|
174
|
+
if (params.reason !== AREA_MEMORY_REASON || params.generation === this.generation) return false;
|
|
175
|
+
this.deps.log(`context injection (${AREA_MEMORY_REASON}) skipped: queued for generation ${String(params.generation)}, now ${this.generation}; the new generation reports its areas again`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A `memory-area` inject reached (or durably parked for) the provider: later reports exclude its ids. */
|
|
180
|
+
noteInjected(params: Record<string, unknown>, memoryIds: readonly string[]): void {
|
|
181
|
+
if (params.reason !== AREA_MEMORY_REASON || params.generation !== this.generation) return;
|
|
182
|
+
for (const id of memoryIds) this.servedEntryIds.add(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A new provider context generation: every area is unentered and nothing is served. */
|
|
186
|
+
resetGeneration(): void {
|
|
187
|
+
this.generation += 1;
|
|
188
|
+
this.servedAreas.clear();
|
|
189
|
+
this.servedEntryIds.clear();
|
|
190
|
+
this.failedReports.clear();
|
|
191
|
+
// The next touch reads the areas again, so a change landed on the default branch takes effect.
|
|
192
|
+
this.lookup = undefined;
|
|
193
|
+
this.pending = [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private touch(absPath: string): void {
|
|
197
|
+
const lookup = this.lookup;
|
|
198
|
+
if (!lookup) return this.queue(absPath);
|
|
199
|
+
if ("matcher" in lookup) for (const area of lookup.matcher.match(absPath)) this.enter(area, lookup.matcher, absPath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Hold a path until this generation's areas load, starting the load once. */
|
|
203
|
+
private queue(absPath?: string): void {
|
|
204
|
+
if (absPath && this.pending.length < MAX_PENDING_PATHS) this.pending.push(absPath);
|
|
205
|
+
if (this.loadingFor === this.generation) return;
|
|
206
|
+
const generation = this.loadingFor = this.generation;
|
|
207
|
+
this.loading = loadAreas(this.deps.cwd()).then((lookup) => {
|
|
208
|
+
// A reset since then dropped these paths; its first touch loads again.
|
|
209
|
+
if (generation !== this.generation) return;
|
|
210
|
+
this.loadingFor = undefined;
|
|
211
|
+
this.lookup = lookup;
|
|
212
|
+
if ("off" in lookup) this.deps.log(`area memory off: ${lookup.off}`);
|
|
213
|
+
for (const path of this.pending.splice(0)) this.touch(path);
|
|
214
|
+
}).catch((error) => this.deps.log(`area memory: tool paths not matched: ${errMessage(error)}`));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private enter(area: string, matcher: AreaMatcher, absPath: string): void {
|
|
218
|
+
if (this.servedAreas.has(area)) return;
|
|
219
|
+
// Added before the POST so a second touch while it runs does not report again.
|
|
220
|
+
this.servedAreas.add(area);
|
|
221
|
+
const generation = this.generation;
|
|
222
|
+
const body = this.body(area, matcher, absPath, generation);
|
|
223
|
+
void this.report(body).catch((error) => this.reportFailed(area, generation, error));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private body(area: string, matcher: AreaMatcher, absPath: string, generation: number): AreaEnteredInput {
|
|
227
|
+
return {
|
|
228
|
+
area,
|
|
229
|
+
relPath: relative(matcher.repoRoot, absPath).split(sep).join("/"),
|
|
230
|
+
providerSessionId: this.deps.providerSessionId(),
|
|
231
|
+
generation,
|
|
232
|
+
exclude: [...this.servedEntryIds].slice(-AREA_EXCLUDE_MAX),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async report(body: AreaEnteredInput, signal?: AbortSignal): Promise<AreaEnteredResult | undefined> {
|
|
237
|
+
const response = await this.deps.http().request("POST", `/api/agents/${encodeURIComponent(this.deps.agentId())}/area-entered`, body, { signal });
|
|
238
|
+
const text = await response.text().catch(() => "");
|
|
239
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
|
|
240
|
+
return body.inline ? JSON.parse(text) as AreaEnteredResult : undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private reportFailed(area: string, generation: number, error: unknown): void {
|
|
244
|
+
// A reset since the POST started already reopened every area.
|
|
245
|
+
if (generation !== this.generation) return;
|
|
246
|
+
const failures = (this.failedReports.get(area) ?? 0) + 1;
|
|
247
|
+
this.failedReports.set(area, failures);
|
|
248
|
+
if (failures >= AREA_REPORT_ATTEMPTS) {
|
|
249
|
+
this.deps.log(`area memory: entry of ${area} not reported after ${failures} attempts; giving up until the next reset: ${errMessage(error)}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.servedAreas.delete(area);
|
|
253
|
+
this.deps.log(`area memory: entry of ${area} not reported (retrying on the next touch): ${errMessage(error)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|