@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,211 @@
|
|
|
1
|
+
import type { Message, ReplyObligation } from "@humwick/sdk";
|
|
2
|
+
import { isHumanAgentId, ACK_REACTION_EMOJIS, errMessage, isMechanicalMessageKind } from "@humwick/sdk";
|
|
3
|
+
import { latestReplyableMessage, type ProviderSessionEvent } from "./adapter";
|
|
4
|
+
import { logger } from "./logger";
|
|
5
|
+
import { receivesTurnResponseAutomatically } from "./response-routing";
|
|
6
|
+
|
|
7
|
+
// Phase 2 (#196) — the crux. The Claude Stop hook used to ask the server, synchronously
|
|
8
|
+
// and in the hot path, "does this agent owe a reply?" before clearing the turn. A slow
|
|
9
|
+
// server answer (the unindexed reply_to scan, #199) blew the hook's timeout and wedged the
|
|
10
|
+
// agent in `busy` forever. The fix: the hook asks the Runner, the Runner answers instantly
|
|
11
|
+
// from this local snapshot, and the snapshot is refreshed from the server only in the
|
|
12
|
+
// background — never on the path that ends a turn.
|
|
13
|
+
//
|
|
14
|
+
// Design rules:
|
|
15
|
+
// - `get()` is synchronous, never throws, never touches the network.
|
|
16
|
+
// - `refresh()` is the only thing that talks to the server; it coalesces concurrent calls
|
|
17
|
+
// and, on failure, keeps the last-known snapshot (stale-but-serving beats blocking).
|
|
18
|
+
// - A background interval keeps the snapshot warm; `markDirty()` requests an extra,
|
|
19
|
+
// debounced refresh when state likely just changed (a message arrived, a turn ended).
|
|
20
|
+
|
|
21
|
+
export function obligationRequiresExplicitReply(obligation: ReplyObligation): boolean {
|
|
22
|
+
if (obligation.source === "spawn-obligation") return false;
|
|
23
|
+
return !receivesTurnResponseAutomatically(obligation.from);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// #2831 — automatic nags (Stop-hook block, idle re-injection) skip an obligation the server marked
|
|
27
|
+
// deferred: its owner is waiting on its own relay_run. The obligation itself stays open.
|
|
28
|
+
export function obligationNagEligible(obligation: ReplyObligation): boolean {
|
|
29
|
+
return obligationRequiresExplicitReply(obligation) && obligation.nagDeferral === undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// #2335 (P1 privacy) — does this obligation's originator sit behind a CHANNEL-EGRESS bridge
|
|
33
|
+
// (`telegram:<account>`, …)? A bridge forwards what it receives to a real human's DM, so the runner's
|
|
34
|
+
// auto-captured turn narration must never be ADDRESSED at one; the obligation itself still stands and
|
|
35
|
+
// still requires an EXPLICIT reply (obligationRequiresExplicitReply is deliberately untouched).
|
|
36
|
+
//
|
|
37
|
+
// The runner has no channel registry — the relay owns that, and owns the routing guard
|
|
38
|
+
// (isDisallowedChannelContent, src/db/channel-egress-allowlist.ts). So this reads the channel facts
|
|
39
|
+
// the server already stamps onto every obligation, and reads them NARROWLY, because a false positive
|
|
40
|
+
// here is not free: it redirects the worker's turn-final capture to the dashboard mirror and drops
|
|
41
|
+
// `replyTo`, which for a peer obligation means the coordinator silently loses its auto report-up.
|
|
42
|
+
//
|
|
43
|
+
// `kind === "channel.event"` — the server's OWN classification of the inbound. `inferMessageKind`
|
|
44
|
+
// types anything from a channel agent this way, so the leaking egress-failure notice matches.
|
|
45
|
+
// `channel === from` — the obligation's channel names the originator ITSELF, i.e. the sender is the
|
|
46
|
+
// bridge. A bare non-empty `channel` is NOT enough: `channel` is a free-form caller-set string on
|
|
47
|
+
// ANY message (`/message <peer> --channel X`, an integration event, a reply that inherited
|
|
48
|
+
// `parent.channel`), so keying on its mere presence caught ordinary peer traffic.
|
|
49
|
+
export function obligationOriginatesFromChannel(obligation: Pick<ReplyObligation, "from" | "kind" | "channel">): boolean {
|
|
50
|
+
return obligation.kind === "channel.event" || obligation.channel === obligation.from;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function responseCaptureReplyRoute(
|
|
54
|
+
obligations: ReplyObligation[],
|
|
55
|
+
currentTurnStartedAt?: number,
|
|
56
|
+
opts: { suppressUser?: boolean } = {},
|
|
57
|
+
): { replyToMessageId?: number; to: string; channelTarget?: boolean } | null {
|
|
58
|
+
const eligible = [...obligations].reverse()
|
|
59
|
+
.filter((o) => currentTurnStartedAt === undefined || o.createdAt <= currentTurnStartedAt);
|
|
60
|
+
const explicit = eligible.find(obligationRequiresExplicitReply);
|
|
61
|
+
if (explicit) {
|
|
62
|
+
return {
|
|
63
|
+
replyToMessageId: explicit.messageId,
|
|
64
|
+
to: explicit.from,
|
|
65
|
+
...(obligationOriginatesFromChannel(explicit) ? { channelTarget: true } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const user = eligible.find((o) => receivesTurnResponseAutomatically(o.from));
|
|
69
|
+
if (!user) return { to: "user" };
|
|
70
|
+
return opts.suppressUser ? null : { replyToMessageId: user.messageId, to: user.from };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ReplyObligationFetch = () => Promise<ReplyObligation[]>;
|
|
74
|
+
|
|
75
|
+
/** The route a turn opened by this delivered batch answers (#2840): its latest replyable message that could
|
|
76
|
+
* carry an obligation — never the agent's own run/timer wake (sent to itself) nor a system/control notice. */
|
|
77
|
+
export function deliveredBatchReplyRoute(messages: Message[], selfId?: string): NonNullable<ProviderSessionEvent["replyRoute"]> | null {
|
|
78
|
+
const replyable = latestReplyableMessage(messages.filter((m) => m.from !== m.to && m.from !== selfId && !isMechanicalMessageKind(m.kind)));
|
|
79
|
+
return replyable ? { to: replyable.from, replyToMessageId: replyable.id, channelTarget: obligationOriginatesFromChannel(replyable), replyExpected: replyable.replyExpected === true } : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** #2840 — the server's "answered" rule (annotateAnsweredMessages): the agent replied to it or later in
|
|
83
|
+
* its thread with a live (non-suppressed) message, or acknowledged it with an ack reaction. */
|
|
84
|
+
export function threadAnswers(thread: Message[], askedId: number, agentId: string): boolean {
|
|
85
|
+
const asked = thread.find((m) => m.id === askedId);
|
|
86
|
+
if (asked?.reactions?.some((r) => r.actorId === agentId && ACK_REACTION_EMOJIS.includes(r.emoji) && r.createdAt >= asked.createdAt)) return true;
|
|
87
|
+
return thread.some((m) => m.from === agentId && m.kind !== "session" && (isHumanAgentId(m.to) || m.payload?.reportSuppression === undefined)
|
|
88
|
+
&& (m.replyTo === askedId || (asked?.threadId !== undefined && m.threadId === asked.threadId && m.id > askedId)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The server's obligation-settle rule: only a live reply with reply_to settles a reply-expected message. */
|
|
92
|
+
function repliesTo(thread: Message[], askedId: number, agentId: string): boolean {
|
|
93
|
+
return thread.some((m) => m.from === agentId && m.kind !== "session" && m.replyTo === askedId && (isHumanAgentId(m.to) || m.payload?.reportSuppression === undefined));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Peer chat has no obligation row and can be acknowledged without a reply, so the thread decides: relay_reply
|
|
97
|
+
// (or 👍) followed by a native final stays single. A route backed by an obligation row (a reply-expected
|
|
98
|
+
// message, or `fromObligationRow`) follows the server's settle rule and is already proven open when unreadable.
|
|
99
|
+
export async function submittedResponseReplyRoute(
|
|
100
|
+
route: ProviderSessionEvent["replyRoute"], getThread: (id: number) => Promise<Message[]>, agentId: string, fromObligationRow = false,
|
|
101
|
+
): Promise<ProviderSessionEvent["replyRoute"]> {
|
|
102
|
+
if (!route) return route;
|
|
103
|
+
const obligation = fromObligationRow || route.replyExpected === true;
|
|
104
|
+
try {
|
|
105
|
+
const thread = await getThread(route.replyToMessageId);
|
|
106
|
+
return (obligation ? repliesTo : threadAnswers)(thread, route.replyToMessageId, agentId) ? null : route;
|
|
107
|
+
} catch { return obligation ? route : null; } // Never guess that an unreachable peer debt is open.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ReplyObligationCacheOptions {
|
|
111
|
+
fetch: ReplyObligationFetch;
|
|
112
|
+
// Background freshness backstop. Default 10s — well under any turn cadence, cheap.
|
|
113
|
+
intervalMs?: number;
|
|
114
|
+
// Debounce window for markDirty()-triggered refreshes so a burst of events
|
|
115
|
+
// (e.g. a fan-out of messages) collapses into one server round-trip.
|
|
116
|
+
dirtyDebounceMs?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_INTERVAL_MS = 10_000;
|
|
120
|
+
const DEFAULT_DIRTY_DEBOUNCE_MS = 400;
|
|
121
|
+
const NAG_REFRESH_TIMEOUT_MS = 1_000;
|
|
122
|
+
|
|
123
|
+
export class ReplyObligationCache {
|
|
124
|
+
private readonly fetch: ReplyObligationFetch;
|
|
125
|
+
private readonly intervalMs: number;
|
|
126
|
+
private readonly dirtyDebounceMs: number;
|
|
127
|
+
|
|
128
|
+
private snapshot: ReplyObligation[] = [];
|
|
129
|
+
private lastRefreshedAt = 0;
|
|
130
|
+
private inFlight: Promise<void> | null = null;
|
|
131
|
+
private intervalTimer?: ReturnType<typeof setInterval>;
|
|
132
|
+
private dirtyTimer?: ReturnType<typeof setTimeout>;
|
|
133
|
+
private stopped = false;
|
|
134
|
+
|
|
135
|
+
constructor(options: ReplyObligationCacheOptions) {
|
|
136
|
+
this.fetch = options.fetch;
|
|
137
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
138
|
+
this.dirtyDebounceMs = options.dirtyDebounceMs ?? DEFAULT_DIRTY_DEBOUNCE_MS;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Synchronous, hot-path-safe read. Returns a copy so callers can't mutate the snapshot.
|
|
142
|
+
get(): ReplyObligation[] {
|
|
143
|
+
return this.snapshot.slice();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getLastRefreshedAt(): number {
|
|
147
|
+
return this.lastRefreshedAt;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Begin the background freshness loop and prime the first snapshot immediately.
|
|
151
|
+
start(): void {
|
|
152
|
+
if (this.intervalTimer || this.stopped) return;
|
|
153
|
+
void this.refresh();
|
|
154
|
+
this.intervalTimer = setInterval(() => { void this.refresh(); }, this.intervalMs);
|
|
155
|
+
// Don't keep the process alive solely for cache refreshes.
|
|
156
|
+
this.intervalTimer.unref?.();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stop(): void {
|
|
160
|
+
this.stopped = true;
|
|
161
|
+
if (this.intervalTimer) clearInterval(this.intervalTimer);
|
|
162
|
+
this.intervalTimer = undefined;
|
|
163
|
+
if (this.dirtyTimer) clearTimeout(this.dirtyTimer);
|
|
164
|
+
this.dirtyTimer = undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Request a refresh because state likely changed (message arrived / turn ended).
|
|
168
|
+
// Debounced so a burst collapses into a single server round-trip.
|
|
169
|
+
markDirty(): void {
|
|
170
|
+
if (this.stopped || this.dirtyTimer) return;
|
|
171
|
+
this.dirtyTimer = setTimeout(() => {
|
|
172
|
+
this.dirtyTimer = undefined;
|
|
173
|
+
void this.refresh();
|
|
174
|
+
}, this.dirtyDebounceMs);
|
|
175
|
+
this.dirtyTimer.unref?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// #2831 — a nag (Stop-hook block, idle re-injection) decided on a stale snapshot can miss a relay_run
|
|
179
|
+
// started this turn, or one that just settled. Re-read once, bounded well under the Stop hook's 2s
|
|
180
|
+
// curl budget, but only when an explicit-reply obligation is in play; otherwise serve the snapshot.
|
|
181
|
+
async freshForNag(timeoutMs = NAG_REFRESH_TIMEOUT_MS): Promise<ReplyObligation[]> {
|
|
182
|
+
if (!this.snapshot.some(obligationRequiresExplicitReply)) return this.get();
|
|
183
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
184
|
+
await Promise.race([this.refresh(), new Promise<void>((resolve) => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
return this.get();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fetch from the server and replace the snapshot. Coalesces concurrent callers onto a
|
|
190
|
+
// single in-flight request. Never rejects — a failed fetch leaves the prior snapshot in
|
|
191
|
+
// place (the hook keeps getting an answer even while the server is down).
|
|
192
|
+
refresh(): Promise<void> {
|
|
193
|
+
if (this.stopped) return Promise.resolve();
|
|
194
|
+
if (this.inFlight) return this.inFlight;
|
|
195
|
+
this.inFlight = this.doRefresh().finally(() => { this.inFlight = null; });
|
|
196
|
+
return this.inFlight;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async doRefresh(): Promise<void> {
|
|
200
|
+
try {
|
|
201
|
+
const obligations = await this.fetch();
|
|
202
|
+
if (this.stopped) return;
|
|
203
|
+
this.snapshot = Array.isArray(obligations) ? obligations : [];
|
|
204
|
+
this.lastRefreshedAt = Date.now();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Server-down is a non-event: keep serving the last snapshot. Debug, not error —
|
|
207
|
+
// this is expected during outages and must not spam the log.
|
|
208
|
+
logger.debug("obligation-cache", `refresh failed, serving cached snapshot (${this.snapshot.length}): ${errMessage(error)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { MessageSessionMeta, SendMessageInput } from "@humwick/sdk";
|
|
2
|
+
|
|
3
|
+
import { receivesTurnResponseAutomatically } from "./response-routing";
|
|
4
|
+
|
|
5
|
+
interface ReportOutbox {
|
|
6
|
+
enqueue(record: { kind: string; idempotencyKey?: string; payload: SendMessageInput }): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type SessionPublisher = (input: {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
body: string;
|
|
13
|
+
session: MessageSessionMeta;
|
|
14
|
+
replyTo?: number;
|
|
15
|
+
}) => void | Promise<void>;
|
|
16
|
+
|
|
17
|
+
/** The turn-final capture's addressing, after the #2335 channel guard has had its say. */
|
|
18
|
+
interface CaptureRoute {
|
|
19
|
+
to: string;
|
|
20
|
+
replyTo?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CapturedResponseInput {
|
|
24
|
+
outbox: ReportOutbox;
|
|
25
|
+
provider: string;
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
body: string;
|
|
29
|
+
session: MessageSessionMeta;
|
|
30
|
+
replyTo?: number;
|
|
31
|
+
/**
|
|
32
|
+
* #2335 — the reply route resolved to a CHANNEL-EGRESS bridge (see obligationOriginatesFromChannel,
|
|
33
|
+
* runner/src/reply-obligation-cache.ts). Set by the caller; absent means "not a channel".
|
|
34
|
+
*/
|
|
35
|
+
channelTarget?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// #1086: dual-stamp the Part A (#1082) turn-finality field alongside the legacy
|
|
39
|
+
// `final` alias so old consumers keep working while new ones can key off the
|
|
40
|
+
// typed `turnFinal` flag directly instead of every writer's own `final: true`.
|
|
41
|
+
function finalSession(session: MessageSessionMeta): MessageSessionMeta {
|
|
42
|
+
return { ...session, turnFinal: true, final: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* #2335 (P1 privacy) — the runner-side half of "auto-captured narration NEVER reaches a channel".
|
|
47
|
+
*
|
|
48
|
+
* What this function captures is the agent's raw terminal narration at turn end: its own running
|
|
49
|
+
* commentary, written for its operator's scrollback. Whenever a reply obligation was open, that text
|
|
50
|
+
* was ADDRESSED at the obligation's originator — and the only originator ever skipped was the literal
|
|
51
|
+
* string `"user"`. That sentinel is far too narrow: the moment the originator is a channel bridge
|
|
52
|
+
* (`telegram:<account>`, e.g. an obligation inherited from a bridge-sent egress-failure notice),
|
|
53
|
+
* `'telegram:default' !== 'user'` held, so the narration was addressed at the bridge and DM'd to that
|
|
54
|
+
* account's allowlisted human. The exposure is generic per account, not specific to one bot.
|
|
55
|
+
*
|
|
56
|
+
* This guard is the SOURCE fix — the cheapest and most surgical of the two layers, since auto-captured
|
|
57
|
+
* narration is never a freeform note and so refusing to emit it at a channel target has zero collateral.
|
|
58
|
+
* It is not the only layer: the relay owns the channel registry and re-decides at both the send-side
|
|
59
|
+
* stamp and the channel-recipient poll (isDisallowedChannelContent, src/db/channel-egress-allowlist.ts),
|
|
60
|
+
* which is what still holds when the sender is a stale runner, an automation, or a raw POST.
|
|
61
|
+
*
|
|
62
|
+
* The capture is not discarded — #1083 requires a turn-final capture to ALWAYS be mirrored. It is
|
|
63
|
+
* re-addressed to the `user` dashboard-mirror sink with the reply link dropped, exactly the shape an
|
|
64
|
+
* autonomous turn with no eligible obligation already produces. Dropping `replyTo` is the point, not a
|
|
65
|
+
* side effect: the channel obligation stays OPEN and still demands an explicit `relay_reply` /
|
|
66
|
+
* `relay_egress_to_channel`, so a channel gets a deliberate, addressed answer or none at all.
|
|
67
|
+
*/
|
|
68
|
+
function captureRoute(input: CapturedResponseInput): CaptureRoute {
|
|
69
|
+
if (input.channelTarget === true) return { to: "user" };
|
|
70
|
+
return { to: input.to, ...(input.replyTo ? { replyTo: input.replyTo } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function publishCapturedResponse(input: CapturedResponseInput & {
|
|
74
|
+
publishSessionEvent: SessionPublisher;
|
|
75
|
+
}): Promise<void> {
|
|
76
|
+
const route = captureRoute(input);
|
|
77
|
+
// #2844 — the enqueued chat report IS the parent delivery, so the mirror stays
|
|
78
|
+
// transcript-only (`user` sink): at most one parent-addressed row per turn-final. (Before,
|
|
79
|
+
// the mirror ALSO went to the parent, doubling opener-answered turns and splitting the
|
|
80
|
+
// #2831/#2840 suppression decision.) The mirror keeps replyTo + turn stamp for lineage.
|
|
81
|
+
const reported = input.replyTo !== undefined && !receivesTurnResponseAutomatically(input.to) && input.channelTarget !== true;
|
|
82
|
+
await input.publishSessionEvent({
|
|
83
|
+
from: input.from,
|
|
84
|
+
to: reported ? "user" : route.to,
|
|
85
|
+
body: input.body,
|
|
86
|
+
...(route.replyTo ? { replyTo: route.replyTo } : {}),
|
|
87
|
+
session: finalSession(input.session),
|
|
88
|
+
});
|
|
89
|
+
enqueueCapturedResponseReport({ ...input, ...route, replyTo: route.replyTo });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function enqueueCapturedResponseReport(input: CapturedResponseInput): void {
|
|
93
|
+
// #2335 — `to === "user"` is kept (the dashboard mirror already carries the turn; a report would
|
|
94
|
+
// double it) but is NO LONGER the only skip: a channel-egress target is dropped outright, whether
|
|
95
|
+
// it arrived here pre-rewritten by captureRoute or straight from a caller that never consulted it.
|
|
96
|
+
if (!input.replyTo || receivesTurnResponseAutomatically(input.to) || input.channelTarget === true) return;
|
|
97
|
+
const turnId = input.session.turnId ?? "";
|
|
98
|
+
input.outbox.enqueue({
|
|
99
|
+
kind: "session-message",
|
|
100
|
+
idempotencyKey: `response-report:${input.from}:${input.replyTo}:${turnId}`,
|
|
101
|
+
payload: {
|
|
102
|
+
from: input.from,
|
|
103
|
+
to: input.to,
|
|
104
|
+
replyTo: input.replyTo,
|
|
105
|
+
kind: "chat",
|
|
106
|
+
body: input.body,
|
|
107
|
+
replyExpected: false,
|
|
108
|
+
payload: { responseCapture: { provider: input.provider, ...finalSession(input.session) } },
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { isHumanAgentId } from "@humwick/sdk/types/users";
|
|
2
|
+
// The runner mirrors every completed turn to the dashboard's user sink, regardless of role.
|
|
3
|
+
// Keep header/nag policy and capture-report routing on the same recipient rule (#3346).
|
|
4
|
+
export function receivesTurnResponseAutomatically(recipient: string): boolean {
|
|
5
|
+
return isHumanAgentId(recipient);
|
|
6
|
+
}
|