@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,253 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
// #2482 — per-turn Claude token-usage capture: the runner-side half of the token-usage
|
|
4
|
+
// spine's first capture seam. On every Stop-hook session turn (runner-core's
|
|
5
|
+
// publishSessionTurn) the transcript's APPENDED tail is read and every NEW root-session
|
|
6
|
+
// assistant entry's API-reported usage is extracted (the #2482 sibling parser beside
|
|
7
|
+
// extractLatestContextUsage in adapters/claude-transcript.ts) and one spine record is
|
|
8
|
+
// POSTed per entry, keyed dedupeKey=entry-uuid so the spine's dedupe_key upsert makes
|
|
9
|
+
// replays and retries idempotent by construction.
|
|
10
|
+
//
|
|
11
|
+
// Two invariants the capture owns:
|
|
12
|
+
// • NO RE-READ — the per-pass position is a byte offset: each turn reads only the bytes
|
|
13
|
+
// appended since the last pass (partial trailing line buffered until its newline
|
|
14
|
+
// arrives, split on 0x0A so multi-byte UTF-8 survives byte boundaries). Per-turn cost
|
|
15
|
+
// is O(new lines), never O(session).
|
|
16
|
+
// • NO DOUBLE-COUNT — the offset watermark commits only after every POST of the pass
|
|
17
|
+
// landed; a failed POST leaves it untouched so the same slice replays next pass. A
|
|
18
|
+
// transcript-path rotation (a committed /clear mints a new session file), an inode
|
|
19
|
+
// change, or a TRUNCATION (same path, size shrank below the offset) re-reads from
|
|
20
|
+
// byte 0 and REPOSTS — safe by construction, because every record keys on the
|
|
21
|
+
// entry uuid and the spine's dedupe_key upsert collapses reposts onto their row
|
|
22
|
+
// (deliberately NOT ordinal-suppressed: counting entries could silently skip a
|
|
23
|
+
// genuinely new one inside a shortened file).
|
|
24
|
+
//
|
|
25
|
+
// All passes are serialized through a single queued worker: requests arriving mid-pass
|
|
26
|
+
// coalesce onto the running slot and the worker drains the latest requested path when
|
|
27
|
+
// the pass finishes — even when that pass failed (first error still reaches the caller).
|
|
28
|
+
// Concurrent Stops for the same transcript can never double-read or double-post, and a
|
|
29
|
+
// rotation racing a pass converges on the newest file.
|
|
30
|
+
|
|
31
|
+
import { errMessage } from "@humwick/sdk";
|
|
32
|
+
import { claudeSessionIdFromTranscriptPath, TranscriptTokenUsageExtractor, type TranscriptTokenUsageEntry } from "./claude-transcript";
|
|
33
|
+
import { incrementalFileNeedsReset, readIncrementalFileRange, splitIncrementalFileLines, statIncrementalFile, type IncrementalFileInfo, type IncrementalFileLineBuffer, type IncrementalFileLinePolicy, type IncrementalFilePosition } from "./incremental-file";
|
|
34
|
+
import type { ProviderTokenUsageRecord } from "../adapter";
|
|
35
|
+
|
|
36
|
+
/** The spine's turn-less report shape (POST /api/agents/:id/token-usage, #2485). */
|
|
37
|
+
export type TranscriptTokenUsageSpineRecord = ProviderTokenUsageRecord & { provider: "claude" };
|
|
38
|
+
|
|
39
|
+
// Claude assistant transcript entries carry both the full model output and usage. Eight
|
|
40
|
+
// MiB accommodates multi-megabyte normal responses while still bounding a line that is
|
|
41
|
+
// left unterminated by a pathological writer.
|
|
42
|
+
export const CLAUDE_TRANSCRIPT_LINE_POLICY: IncrementalFileLinePolicy = { maxPartialBytes: 8 * 1024 * 1024 };
|
|
43
|
+
|
|
44
|
+
export interface ClaudeTranscriptTokenUsageCaptureDeps {
|
|
45
|
+
subagent?: { id: string; sessionId: string };
|
|
46
|
+
post: (record: TranscriptTokenUsageSpineRecord) => Promise<unknown>;
|
|
47
|
+
stat?: (path: string) => Promise<IncrementalFileInfo>;
|
|
48
|
+
/** Bytes in [start, end) of the transcript file — the incremental tail read. */
|
|
49
|
+
readRange?: (path: string, start: number, end: number) => Promise<Uint8Array>;
|
|
50
|
+
debug?: (message: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function spineRecordForEntry(sessionId: string, entry: TranscriptTokenUsageEntry): TranscriptTokenUsageSpineRecord {
|
|
54
|
+
return {
|
|
55
|
+
sessionId,
|
|
56
|
+
provider: "claude",
|
|
57
|
+
model: entry.model,
|
|
58
|
+
inputTokens: entry.inputTokens,
|
|
59
|
+
outputTokens: entry.outputTokens,
|
|
60
|
+
cacheReadTokens: entry.cacheReadTokens,
|
|
61
|
+
cacheWriteTokens: entry.cacheWriteTokens,
|
|
62
|
+
dedupeKey: entry.uuid,
|
|
63
|
+
createdAt: entry.occurredAt,
|
|
64
|
+
...(entry.usageStatus ? { usageStatus: entry.usageStatus } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ClaudeTranscriptTokenUsageCapture {
|
|
69
|
+
private readonly children = new Map<string, ClaudeTranscriptTokenUsageCapture>();
|
|
70
|
+
private childSessionId?: string;
|
|
71
|
+
private position: IncrementalFilePosition | undefined;
|
|
72
|
+
private lineBuffer: IncrementalFileLineBuffer = {};
|
|
73
|
+
private pending: { path: string; promise: Promise<number> } | undefined;
|
|
74
|
+
private settling?: Promise<void>;
|
|
75
|
+
|
|
76
|
+
constructor(private readonly deps: ClaudeTranscriptTokenUsageCaptureDeps) {}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Report the transcript's not-yet-reported assistant usage entries. Serialized: at
|
|
80
|
+
* most one pass runs at a time; a request arriving mid-pass coalesces onto the queued
|
|
81
|
+
* slot (latest path wins) and the worker drains it after the current pass — even when
|
|
82
|
+
* that pass failed, with the first error still propagated to the caller. Never
|
|
83
|
+
* rejects for an unreadable transcript (debug note; the next pass re-reads); a failed
|
|
84
|
+
* POST propagates to the caller's catch with the position held for a next-pass retry.
|
|
85
|
+
*/
|
|
86
|
+
capture(transcriptPath: string): Promise<number> {
|
|
87
|
+
return this.settling ? this.settling.then(() => this.capturePassQueued(transcriptPath)) : this.capturePassQueued(transcriptPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private capturePassQueued(transcriptPath: string): Promise<number> {
|
|
91
|
+
if (this.pending) {
|
|
92
|
+
this.pending.path = transcriptPath;
|
|
93
|
+
return this.pending.promise;
|
|
94
|
+
}
|
|
95
|
+
const slot: { path: string; promise: Promise<number> } = { path: transcriptPath, promise: Promise.resolve(0) };
|
|
96
|
+
slot.promise = (async () => {
|
|
97
|
+
// Error presence is tracked by flag, not the undefined sentinel: JS permits
|
|
98
|
+
// `throw undefined`, and `??=` would both swallow that pass's error entirely and
|
|
99
|
+
// let a later pass's error masquerade as first (round-4).
|
|
100
|
+
let hasFirstError = false;
|
|
101
|
+
let firstError: unknown;
|
|
102
|
+
let postedCount = 0;
|
|
103
|
+
try {
|
|
104
|
+
do {
|
|
105
|
+
const target = slot.path;
|
|
106
|
+
slot.path = "";
|
|
107
|
+
try {
|
|
108
|
+
postedCount += await this.capturePass(target);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Retain and keep draining: a failed pass must not strand the queued
|
|
111
|
+
// latest-path request (round-3). The first error still reaches the caller.
|
|
112
|
+
if (!hasFirstError) {
|
|
113
|
+
firstError = error;
|
|
114
|
+
hasFirstError = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} while (slot.path);
|
|
118
|
+
} finally {
|
|
119
|
+
this.pending = undefined;
|
|
120
|
+
}
|
|
121
|
+
if (hasFirstError) throw firstError;
|
|
122
|
+
return postedCount;
|
|
123
|
+
})();
|
|
124
|
+
this.pending = slot;
|
|
125
|
+
return slot.promise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A monitorless pane can render idle before Claude flushes its final assistant
|
|
130
|
+
* JSONL entry. Keep draining the incremental tail through the runner-owned
|
|
131
|
+
* bounded settle window. Once an entry arrives, wait for a short quiet interval
|
|
132
|
+
* too: an older delayed entry must not make us abandon the ending turn's own
|
|
133
|
+
* line when both are being flushed.
|
|
134
|
+
*/
|
|
135
|
+
captureWithSettle(transcriptPath: string, options: { pollIntervalMs: number; timeoutMs: number; now?: () => number; sleep?: (ms: number) => Promise<unknown> }): Promise<number> {
|
|
136
|
+
const settle = (this.settling ?? Promise.resolve()).then(() => this.settleTranscript(transcriptPath, options));
|
|
137
|
+
const barrier = settle.then(() => {}, () => {});
|
|
138
|
+
this.settling = barrier;
|
|
139
|
+
void barrier.then(() => { if (this.settling === barrier) this.settling = undefined; });
|
|
140
|
+
return settle;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async settleTranscript(transcriptPath: string, options: { pollIntervalMs: number; timeoutMs: number; now?: () => number; sleep?: (ms: number) => Promise<unknown> }): Promise<number> {
|
|
144
|
+
// now/sleep are injectable so tests can drive the settle window on a virtual
|
|
145
|
+
// clock instead of racing real timers against host load (#2714 flake).
|
|
146
|
+
const now = options.now ?? Date.now;
|
|
147
|
+
const sleep = options.sleep ?? Bun.sleep;
|
|
148
|
+
const deadline = now() + options.timeoutMs;
|
|
149
|
+
const pollIntervalMs = Math.max(1, options.pollIntervalMs);
|
|
150
|
+
const quietWindowMs = Math.min(options.timeoutMs, Math.max(pollIntervalMs * 2, 25));
|
|
151
|
+
// #2491 review round 3 (reverts round 2's watermark bail, which was WRONG — it exited
|
|
152
|
+
// exactly when the watermark hadn't moved yet, which is precisely the state a still-
|
|
153
|
+
// flushing final entry looks like before it lands). Seed the quiet-window baseline at
|
|
154
|
+
// settle START rather than leaving it undefined until something is first found: without
|
|
155
|
+
// this, when nothing EVER arrives (routine — the turn's own Stop-hook pass already
|
|
156
|
+
// caught everything) the quiet-window exit never fires and the loop burns the full
|
|
157
|
+
// settlePollTimeoutMs on every hooked teardown. Seeding it here bounds that case to
|
|
158
|
+
// quietWindowMs of continued silence instead — still comfortably wider than the ms-scale
|
|
159
|
+
// flush race this settle exists to catch, just not the full timeout.
|
|
160
|
+
let lastNewEntryAt: number = now();
|
|
161
|
+
|
|
162
|
+
let reported = await this.capturePassQueued(transcriptPath);
|
|
163
|
+
if (reported) lastNewEntryAt = now();
|
|
164
|
+
while (now() < deadline) {
|
|
165
|
+
await sleep(Math.max(1, Math.min(pollIntervalMs, deadline - now())));
|
|
166
|
+
const count = await this.capturePassQueued(transcriptPath);
|
|
167
|
+
reported += count;
|
|
168
|
+
if (count) {
|
|
169
|
+
lastNewEntryAt = now();
|
|
170
|
+
} else if (now() - lastNewEntryAt >= quietWindowMs) {
|
|
171
|
+
return reported;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return reported;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async captureSubagent(transcriptPath: string, settle = false): Promise<number> {
|
|
178
|
+
if (basename(dirname(transcriptPath)) !== "subagents") throw new Error("Expected a Claude sub-agent transcript");
|
|
179
|
+
const match = /^agent-(.+)\.jsonl$/.exec(basename(transcriptPath));
|
|
180
|
+
if (!match) throw new Error("Expected an agent-<id>.jsonl transcript");
|
|
181
|
+
const sessionId = basename(dirname(dirname(transcriptPath)));
|
|
182
|
+
if (sessionId !== this.childSessionId) { this.children.clear(); this.childSessionId = sessionId; }
|
|
183
|
+
let child = this.children.get(transcriptPath);
|
|
184
|
+
if (!child) {
|
|
185
|
+
child = new ClaudeTranscriptTokenUsageCapture({ ...this.deps, subagent: { id: match[1]!, sessionId } });
|
|
186
|
+
this.children.set(transcriptPath, child);
|
|
187
|
+
}
|
|
188
|
+
if (settle) return child.captureWithSettle(transcriptPath, { pollIntervalMs: 25, timeoutMs: 1_000 });
|
|
189
|
+
return child.capture(transcriptPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async capturePass(transcriptPath: string): Promise<number> {
|
|
193
|
+
let count = await this.captureTranscriptPass(transcriptPath);
|
|
194
|
+
if (this.deps.subagent) return count;
|
|
195
|
+
const directory = join(dirname(transcriptPath), basename(transcriptPath, ".jsonl"), "subagents");
|
|
196
|
+
let files;
|
|
197
|
+
try { files = await readdir(directory, { withFileTypes: true }); }
|
|
198
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return count; throw error; }
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
if (file.isFile() && /^agent-.+\.jsonl$/.test(file.name)) count += await this.captureSubagent(join(directory, file.name));
|
|
201
|
+
}
|
|
202
|
+
return count;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async captureTranscriptPass(transcriptPath: string): Promise<number> {
|
|
206
|
+
const stat = this.deps.stat ?? statIncrementalFile;
|
|
207
|
+
let info: IncrementalFileInfo;
|
|
208
|
+
try {
|
|
209
|
+
info = await stat(transcriptPath);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (this.deps.subagent) throw error;
|
|
212
|
+
this.deps.debug?.(`token-usage capture: transcript unreadable: ${errMessage(error)}`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// New path / inode rotation / same-path truncation (size shrank below the offset):
|
|
217
|
+
// read again from byte 0. Reposts are SAFE, not an error — the spine collapses each
|
|
218
|
+
// one onto its existing row via dedupeKey=entry-uuid — and a genuinely new entry
|
|
219
|
+
// carried by a truncated/shortened file still reports. (Count-based ordinal
|
|
220
|
+
// suppression was tried here and could silently skip such an entry — round-3.)
|
|
221
|
+
if (incrementalFileNeedsReset(this.position, transcriptPath, info)) {
|
|
222
|
+
this.position = { path: transcriptPath, ino: info.ino, offset: 0 };
|
|
223
|
+
this.lineBuffer = {};
|
|
224
|
+
}
|
|
225
|
+
const position = this.position;
|
|
226
|
+
if (!position || info.size <= position.offset) return 0; // nothing appended since the last pass
|
|
227
|
+
|
|
228
|
+
const chunk = await (this.deps.readRange ?? readIncrementalFileRange)(transcriptPath, position.offset, info.size);
|
|
229
|
+
const { lines, buffer } = splitIncrementalFileLines(this.lineBuffer, chunk, CLAUDE_TRANSCRIPT_LINE_POLICY);
|
|
230
|
+
|
|
231
|
+
const extractor = new TranscriptTokenUsageExtractor(0, Boolean(this.deps.subagent));
|
|
232
|
+
const newEntries: TranscriptTokenUsageEntry[] = [];
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
const entry = extractor.appendLine(line.toString("utf8"));
|
|
235
|
+
if (entry) newEntries.push(entry);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const sessionId = this.deps.subagent?.sessionId ?? claudeSessionIdFromTranscriptPath(transcriptPath);
|
|
239
|
+
if (!sessionId) {
|
|
240
|
+
this.deps.debug?.("token-usage capture: no session id in transcript path; skipping");
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
for (const entry of newEntries) {
|
|
244
|
+
await this.deps.post({ ...spineRecordForEntry(sessionId, entry), ...(this.deps.subagent ? { subagentId: this.deps.subagent.id } : {}) });
|
|
245
|
+
}
|
|
246
|
+
// Commit only after every POST of the pass landed — a throw above leaves the
|
|
247
|
+
// position and partial buffer untouched, so the whole slice replays next pass
|
|
248
|
+
// (safe: uuid-keyed, spine-deduped).
|
|
249
|
+
position.offset = info.size;
|
|
250
|
+
this.lineBuffer = buffer;
|
|
251
|
+
return newEntries.length;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { writeChildEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { harnessSubagentsEnabled } from "@humwick/sdk";
|
|
3
|
+
import type { RunnerSpawnConfig } from "../adapter";
|
|
4
|
+
import type { AgentProfileProviderOptions } from "@humwick/sdk";
|
|
5
|
+
import { isRecord } from "@humwick/sdk/types/guards";
|
|
6
|
+
import { claudeDisallowedWebTools } from "../web-tools";
|
|
7
|
+
|
|
8
|
+
// ── #2453 — eager "System tools" context trim for the coding/review profiles ─────────────────
|
|
9
|
+
// A BARE tool name on Claude Code's --disallowedTools removes the tool's schema from the
|
|
10
|
+
// model's eager context (the /context "System tools" bucket) WITHOUT touching MCP (mcp__*),
|
|
11
|
+
// skills, or core coding tools — which an allowlist (--tools) would put at risk. The Artifact
|
|
12
|
+
// family + Workflow carry the heaviest eager docstrings and coding/review agents never publish
|
|
13
|
+
// artifacts or run workflows; SendFeedback is misused by autonomous agents (also env-suppressed
|
|
14
|
+
// fleet-wide since T3182 — this is the per-profile context-trim path); ScheduleWakeup is
|
|
15
|
+
// loop-only; ReportFindings duplicates relay report-up.
|
|
16
|
+
export const CLAUDE_CONTEXT_TRIM_DISALLOWED_TOOLS = ["Artifact", "ArtifactComments", "ArtifactData", "ArtifactCheck", "Workflow", "SendFeedback", "ScheduleWakeup", "ReportFindings"];
|
|
17
|
+
|
|
18
|
+
// The coding/review built-ins the denylist defaults ON for: worker/debugger build+test+land,
|
|
19
|
+
// reviewer review, probe one-shot checks. Coordinators and ops-assistant
|
|
20
|
+
// legitimately use Artifact/Workflow/ScheduleWakeup, so they keep every tool. A profile retunes
|
|
21
|
+
// this per-profile via providerOptions.claude.disallowedTools.
|
|
22
|
+
const CLAUDE_CONTEXT_TRIM_DEFAULT_PROFILES = new Set(["worker", "debugger", "reviewer", "probe"]);
|
|
23
|
+
|
|
24
|
+
const CLAUDE_DISALLOWED_TOOLS_ARG = /^--(?:disallowedTools|disallowed-tools)(?:=(.*))?$/;
|
|
25
|
+
|
|
26
|
+
/** Effective #2453 denylist for this launch: a profile-configured array REPLACES the default,
|
|
27
|
+
* `null` opts the profile out entirely, absent falls back to the default — and only for the
|
|
28
|
+
* coding/review profiles. Resolves the profile via the established
|
|
29
|
+
* `agentProfile?.name || profile` pattern. */
|
|
30
|
+
export function claudeProfileDisallowedTools(config: Pick<RunnerSpawnConfig, "profile" | "agentProfile">): string[] {
|
|
31
|
+
const providerOptions = config.agentProfile?.providerOptions;
|
|
32
|
+
// A whole-null claude section is an explicit per-provider opt-out, same as
|
|
33
|
+
// disallowedTools: null — NOT "absent, fall back to the profile default".
|
|
34
|
+
if (isRecord(providerOptions) && "claude" in providerOptions && providerOptions.claude === null) return [];
|
|
35
|
+
const claudeOptions = isRecord(providerOptions) && isRecord(providerOptions.claude) ? providerOptions.claude : undefined;
|
|
36
|
+
if (claudeOptions && "disallowedTools" in claudeOptions) {
|
|
37
|
+
if (claudeOptions.disallowedTools === null) return [];
|
|
38
|
+
if (Array.isArray(claudeOptions.disallowedTools)) {
|
|
39
|
+
return claudeOptions.disallowedTools.filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const profileName = config.agentProfile?.name || config.profile || "";
|
|
43
|
+
if (!CLAUDE_CONTEXT_TRIM_DEFAULT_PROFILES.has(profileName)) return [];
|
|
44
|
+
// A profile that switches the Artifact or Workflow tool back on (#3171) gets it.
|
|
45
|
+
const harness = config.agentProfile ? claudeHarnessOptions(config) : CLAUDE_HARNESS_DEFAULTS;
|
|
46
|
+
return CLAUDE_CONTEXT_TRIM_DISALLOWED_TOOLS.filter((tool) => tool === "Workflow" ? !harness.workflows : !tool.startsWith("Artifact") || !harness.artifacts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── #3171 — harness context trim: Claude Code built-ins Relay agents never use ──────────────────
|
|
50
|
+
export type ClaudeAgentTypes = NonNullable<NonNullable<AgentProfileProviderOptions["claude"]>["agentTypes"]>;
|
|
51
|
+
const CLAUDE_AGENT_TYPES: readonly ClaudeAgentTypes[] = ["all", "general-purpose", "none"];
|
|
52
|
+
export interface ClaudeHarnessOptions {
|
|
53
|
+
artifacts: boolean;
|
|
54
|
+
workflows: boolean;
|
|
55
|
+
bundledSkills: boolean;
|
|
56
|
+
agentTypes: ClaudeAgentTypes;
|
|
57
|
+
/** AskUserQuestion + ListAgents: tools for a human at the terminal or native peer sessions. */
|
|
58
|
+
interactiveTools: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every Relay-launched profile gets this unless its providerOptions.claude says otherwise; the
|
|
62
|
+
// built-in exceptions (coordinator, software-design, probe, ...) live in src/agent-profile-catalog.ts.
|
|
63
|
+
const CLAUDE_HARNESS_DEFAULTS: ClaudeHarnessOptions = { artifacts: false, workflows: false, bundledSkills: false, agentTypes: "general-purpose", interactiveTools: false };
|
|
64
|
+
// Profile-less launches (a human's wrapped `claude`) and a whole-null claude section keep everything.
|
|
65
|
+
const CLAUDE_HARNESS_KEEP_ALL: ClaudeHarnessOptions = { artifacts: true, workflows: true, bundledSkills: true, agentTypes: "all", interactiveTools: true };
|
|
66
|
+
|
|
67
|
+
// Claude Code 2.1.310's bundled skills, grouped by the tool that needs them. The env switch drops
|
|
68
|
+
// them all, so a profile with Artifact or Workflow on turns the others off one by one instead.
|
|
69
|
+
const ARTIFACT_SKILLS = ["artifact-design", "artifact-diagramming", "artifact-capabilities", "dataviz"];
|
|
70
|
+
const WORKFLOW_SKILLS = ["workflow-authoring"];
|
|
71
|
+
const OTHER_BUNDLED_SKILLS = ["claude-api", "code-review", "security-review", "update-config", "schedule", "run", "loop", "keybindings-help", "simplify", "fewer-permission-prompts", "init"];
|
|
72
|
+
// Survives CLAUDE_CODE_DISABLE_BUNDLED_SKILLS; only skillOverrides removes it.
|
|
73
|
+
const PLUGIN_AUTHORING_SKILL = "plugin-authoring";
|
|
74
|
+
// Explore/Plan go through CLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS; these need a deny.
|
|
75
|
+
const NON_GENERAL_AGENT_TYPES = ["claude-code-guide", "statusline-setup", "claude"];
|
|
76
|
+
|
|
77
|
+
export function claudeHarnessOptions(config: Pick<RunnerSpawnConfig, "agentProfile">): ClaudeHarnessOptions {
|
|
78
|
+
const providerOptions = config.agentProfile?.providerOptions;
|
|
79
|
+
if (!config.agentProfile || (isRecord(providerOptions) && "claude" in providerOptions && providerOptions.claude === null)) return CLAUDE_HARNESS_KEEP_ALL;
|
|
80
|
+
const claude = isRecord(providerOptions) && isRecord(providerOptions.claude) ? providerOptions.claude : {};
|
|
81
|
+
const flag = (key: "artifacts" | "workflows" | "bundledSkills" | "interactiveTools") => typeof claude[key] === "boolean" ? claude[key] as boolean : CLAUDE_HARNESS_DEFAULTS[key];
|
|
82
|
+
const agentTypes = CLAUDE_AGENT_TYPES.find((value) => value === claude.agentTypes) ?? CLAUDE_HARNESS_DEFAULTS.agentTypes;
|
|
83
|
+
return { artifacts: flag("artifacts"), workflows: flag("workflows"), bundledSkills: flag("bundledSkills"), agentTypes, interactiveTools: flag("interactiveTools") };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ClaudeHarnessTrim {
|
|
87
|
+
env: Record<string, string>;
|
|
88
|
+
/** Keys for the inline --settings payload. */
|
|
89
|
+
settings: Record<string, unknown>;
|
|
90
|
+
/** Bare or `Agent(<type>)` names for --disallowedTools. */
|
|
91
|
+
deny: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The launch levers for one profile's harness options: env switches, skillOverrides and denies. */
|
|
95
|
+
export function claudeHarnessTrim(config: Pick<RunnerSpawnConfig, "agentProfile">): ClaudeHarnessTrim {
|
|
96
|
+
const options = claudeHarnessOptions(config);
|
|
97
|
+
const env: Record<string, string> = {};
|
|
98
|
+
if (!options.artifacts) env.CLAUDE_CODE_DISABLE_ARTIFACT = "1";
|
|
99
|
+
if (!options.workflows) env.CLAUDE_CODE_DISABLE_WORKFLOWS = "1";
|
|
100
|
+
if (options.agentTypes !== "all") env.CLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS = "1";
|
|
101
|
+
const skillsOff: string[] = [];
|
|
102
|
+
if (!options.bundledSkills) {
|
|
103
|
+
if (options.artifacts || options.workflows) {
|
|
104
|
+
skillsOff.push(...OTHER_BUNDLED_SKILLS, ...(options.artifacts ? [] : ARTIFACT_SKILLS), ...(options.workflows ? [] : WORKFLOW_SKILLS));
|
|
105
|
+
} else {
|
|
106
|
+
env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = "1";
|
|
107
|
+
}
|
|
108
|
+
skillsOff.push(PLUGIN_AUTHORING_SKILL);
|
|
109
|
+
}
|
|
110
|
+
const deny = [
|
|
111
|
+
...(options.interactiveTools ? [] : ["AskUserQuestion", "ListAgents"]),
|
|
112
|
+
...(options.agentTypes === "none" ? ["Agent"] : options.agentTypes === "general-purpose" ? NON_GENERAL_AGENT_TYPES.map((type) => `Agent(${type})`) : []),
|
|
113
|
+
];
|
|
114
|
+
return { env, settings: skillsOff.length ? { skillOverrides: Object.fromEntries(skillsOff.map((skill) => [skill, "off"])) } : {}, deny };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One scan over the launch args: the tool names carried by inherited
|
|
118
|
+
* `--disallowedTools`/`--disallowed-tools` entries (space-separated multi-value and `=`
|
|
119
|
+
* comma-list forms), plus the args with those entries removed — so the profile denylist can
|
|
120
|
+
* UNION with what a caller/provider config already asked for instead of clobbering it. */
|
|
121
|
+
function partitionClaudeDisallowedToolsArgs(args: string[]): { kept: string[]; inherited: string[] } {
|
|
122
|
+
const kept: string[] = [];
|
|
123
|
+
const inherited: string[] = [];
|
|
124
|
+
for (let i = 0; i < args.length; i++) {
|
|
125
|
+
const arg = args[i];
|
|
126
|
+
const match = typeof arg === "string" ? CLAUDE_DISALLOWED_TOOLS_ARG.exec(arg) : null;
|
|
127
|
+
if (!match) {
|
|
128
|
+
kept.push(arg as string);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (match[1] !== undefined) {
|
|
132
|
+
inherited.push(...splitDisallowedToolsList(match[1]));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
for (i++; i < args.length; i++) {
|
|
136
|
+
const value = args[i];
|
|
137
|
+
if (value === undefined || value.startsWith("--")) break;
|
|
138
|
+
inherited.push(...splitDisallowedToolsList(value));
|
|
139
|
+
}
|
|
140
|
+
i--;
|
|
141
|
+
}
|
|
142
|
+
return { kept, inherited };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function splitDisallowedToolsList(value: string): string[] {
|
|
146
|
+
return value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Appends the profile's #2453 denylist to the launch args, merging with any inherited
|
|
150
|
+
* --disallowedTools entries into ONE canonical trailing occurrence (union, never clobber).
|
|
151
|
+
* No-op (args returned as-is) when the profile resolves no denylist. The single launch seam
|
|
152
|
+
* for this trim is ClaudeAdapter.buildSpawnArgs.
|
|
153
|
+
* Emitted as ONE `--disallowedTools=a,b,...` token: the space-separated variadic form would
|
|
154
|
+
* consume every following non-flag token — buildSpawnArgs pushes --model only when set and
|
|
155
|
+
* then the seeded initial-prompt positional, so a trailing variadic on an unpinned-model
|
|
156
|
+
* launch would eat the prompt as a deny value (silent no-prompt launch). The `=` form
|
|
157
|
+
* terminates the flag so following positionals survive. */
|
|
158
|
+
export function claudeProfileDisallowedToolsArgs(args: string[], config: Pick<RunnerSpawnConfig, "harnessSubagents" | "profile" | "agentProfile">): string[] {
|
|
159
|
+
// #3024 — a profile's switched-off web tools are hidden whatever its denylist says.
|
|
160
|
+
const deny = [...(harnessSubagentsEnabled(config) ? [] : ["Agent", "Task", "TeamCreate", "TeamDelete", "SendMessage"]), ...claudeProfileDisallowedTools(config), ...claudeHarnessTrim(config).deny, ...claudeDisallowedWebTools(config)];
|
|
161
|
+
if (!deny.length) return args;
|
|
162
|
+
const { kept, inherited } = partitionClaudeDisallowedToolsArgs(args);
|
|
163
|
+
const merged = [...new Set([...inherited, ...deny])];
|
|
164
|
+
return [...kept, `--disallowedTools=${merged.join(",")}`];
|
|
165
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
|
+
import { LatestTurnStepAccumulator, type TurnStep } from "./claude-transcript";
|
|
3
|
+
|
|
4
|
+
const FINGERPRINT_BYTES = 4096;
|
|
5
|
+
|
|
6
|
+
export interface ClaudeTranscriptFileIdentity {
|
|
7
|
+
dev: number;
|
|
8
|
+
ino: number;
|
|
9
|
+
birthtimeMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TailFingerprint {
|
|
13
|
+
offset: number;
|
|
14
|
+
bytes: Buffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TranscriptTailFs {
|
|
18
|
+
stat: typeof stat;
|
|
19
|
+
open: typeof open;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IncrementalTranscriptPollResult {
|
|
23
|
+
available: boolean;
|
|
24
|
+
changed: boolean;
|
|
25
|
+
reset: boolean;
|
|
26
|
+
steps: TurnStep[];
|
|
27
|
+
transcriptLooksComplete: boolean;
|
|
28
|
+
/** Claude's native prompt id for the latest turn, when known. */
|
|
29
|
+
promptId?: string;
|
|
30
|
+
/** Real user entries appended since this tail's previous poll. */
|
|
31
|
+
userPrompts: string[];
|
|
32
|
+
/** Entry timestamps corresponding to userPrompts, when Claude supplied them. */
|
|
33
|
+
userPromptOccurredAts: Array<number | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class IncrementalClaudeTranscriptTail {
|
|
37
|
+
private offset = 0;
|
|
38
|
+
private pending = Buffer.alloc(0);
|
|
39
|
+
private identity: ClaudeTranscriptFileIdentity | undefined;
|
|
40
|
+
private fingerprint: TailFingerprint | undefined;
|
|
41
|
+
private newUserPrompts: string[] = [];
|
|
42
|
+
private newUserPromptOccurredAts: Array<number | undefined> = [];
|
|
43
|
+
private readonly accumulator = new LatestTurnStepAccumulator();
|
|
44
|
+
|
|
45
|
+
constructor(private readonly fs: TranscriptTailFs = { stat, open }) {}
|
|
46
|
+
|
|
47
|
+
/** Start watching at the current EOF without parsing an old, potentially huge session. */
|
|
48
|
+
async seekToEnd(path: string): Promise<boolean> {
|
|
49
|
+
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
50
|
+
try { fileStat = await this.fs.stat(path); } catch { return false; }
|
|
51
|
+
const length = Math.min(FINGERPRINT_BYTES, fileStat.size);
|
|
52
|
+
const buffer = Buffer.alloc(length);
|
|
53
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
54
|
+
try { handle = await this.fs.open(path, "r"); } catch { return false; }
|
|
55
|
+
try {
|
|
56
|
+
const read = await handle.read(buffer, 0, length, fileStat.size - length);
|
|
57
|
+
if (read.bytesRead !== length) return false;
|
|
58
|
+
} finally { await handle.close(); }
|
|
59
|
+
this.offset = fileStat.size;
|
|
60
|
+
this.pending = Buffer.alloc(0);
|
|
61
|
+
this.identity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
|
|
62
|
+
this.fingerprint = { offset: fileStat.size - length, bytes: buffer };
|
|
63
|
+
this.accumulator.reset();
|
|
64
|
+
this.newUserPrompts = [];
|
|
65
|
+
this.newUserPromptOccurredAts = [];
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async poll(path: string): Promise<IncrementalTranscriptPollResult> {
|
|
70
|
+
this.newUserPrompts = [];
|
|
71
|
+
this.newUserPromptOccurredAts = [];
|
|
72
|
+
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
73
|
+
try {
|
|
74
|
+
fileStat = await this.fs.stat(path);
|
|
75
|
+
} catch {
|
|
76
|
+
return this.result({ available: false, changed: false, reset: false });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const nextIdentity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
|
|
80
|
+
const replaced = this.identity !== undefined && !sameIdentity(this.identity, nextIdentity);
|
|
81
|
+
const truncated = this.offset > fileStat.size;
|
|
82
|
+
let reset = replaced || truncated;
|
|
83
|
+
if (!reset && this.identity && this.fingerprint && fileStat.size >= this.offset) {
|
|
84
|
+
reset = !(await this.fingerprintMatches(path));
|
|
85
|
+
}
|
|
86
|
+
if (reset) this.reset(nextIdentity);
|
|
87
|
+
else if (!this.identity) this.identity = nextIdentity;
|
|
88
|
+
|
|
89
|
+
if (this.offset === fileStat.size) {
|
|
90
|
+
return this.result({ available: true, changed: false, reset });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const length = fileStat.size - this.offset;
|
|
94
|
+
const buffer = Buffer.alloc(length);
|
|
95
|
+
let bytesRead = 0;
|
|
96
|
+
// A transcript can be rotated after stat() but before open(). That is ordinary lifecycle
|
|
97
|
+
// churn, not a runner-fatal error: report this poll as unavailable and retry next interval.
|
|
98
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
99
|
+
try {
|
|
100
|
+
handle = await this.fs.open(path, "r");
|
|
101
|
+
} catch {
|
|
102
|
+
return this.result({ available: false, changed: false, reset });
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const read = await handle.read(buffer, 0, length, this.offset);
|
|
106
|
+
bytesRead = read.bytesRead;
|
|
107
|
+
} finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.offset += bytesRead;
|
|
112
|
+
const delta = buffer.subarray(0, bytesRead);
|
|
113
|
+
this.updateFingerprint(delta);
|
|
114
|
+
const changed = bytesRead > 0 && this.consume(delta);
|
|
115
|
+
// A reset replays the new file from byte zero. Its history predates this
|
|
116
|
+
// submission probe, even when an old user entry happens to match this prompt.
|
|
117
|
+
if (reset) {
|
|
118
|
+
this.newUserPrompts = [];
|
|
119
|
+
this.newUserPromptOccurredAts = [];
|
|
120
|
+
}
|
|
121
|
+
return this.result({ available: true, changed, reset });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private reset(identity: ClaudeTranscriptFileIdentity): void {
|
|
125
|
+
this.offset = 0;
|
|
126
|
+
this.pending = Buffer.alloc(0);
|
|
127
|
+
this.fingerprint = undefined;
|
|
128
|
+
this.identity = identity;
|
|
129
|
+
this.accumulator.reset();
|
|
130
|
+
this.newUserPrompts = [];
|
|
131
|
+
this.newUserPromptOccurredAts = [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async fingerprintMatches(path: string): Promise<boolean> {
|
|
135
|
+
if (!this.fingerprint) return true;
|
|
136
|
+
const buffer = Buffer.alloc(this.fingerprint.bytes.length);
|
|
137
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
138
|
+
try {
|
|
139
|
+
handle = await this.fs.open(path, "r");
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const read = await handle.read(buffer, 0, buffer.length, this.fingerprint.offset);
|
|
145
|
+
return read.bytesRead === buffer.length && buffer.equals(this.fingerprint.bytes);
|
|
146
|
+
} finally {
|
|
147
|
+
await handle.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private updateFingerprint(delta: Buffer): void {
|
|
152
|
+
if (!delta.length) return;
|
|
153
|
+
const previous = this.fingerprint?.bytes ?? Buffer.alloc(0);
|
|
154
|
+
const combined = previous.length ? Buffer.concat([previous, delta]) : delta;
|
|
155
|
+
const bytes = Buffer.from(combined.subarray(Math.max(0, combined.length - FINGERPRINT_BYTES)));
|
|
156
|
+
this.fingerprint = { offset: this.offset - bytes.length, bytes };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private consume(delta: Buffer): boolean {
|
|
160
|
+
if (!delta.length) return false;
|
|
161
|
+
const combined = this.pending.length ? Buffer.concat([this.pending, delta]) : delta;
|
|
162
|
+
const lastNewline = combined.lastIndexOf(0x0a);
|
|
163
|
+
if (lastNewline === -1) {
|
|
164
|
+
this.pending = Buffer.from(combined);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const complete = combined.subarray(0, lastNewline + 1);
|
|
169
|
+
this.pending = Buffer.from(combined.subarray(lastNewline + 1));
|
|
170
|
+
this.accumulator.appendJsonl(complete.toString("utf8"));
|
|
171
|
+
const prompts = this.accumulator.drainUserPrompts();
|
|
172
|
+
this.newUserPrompts = prompts.map((prompt) => prompt.text);
|
|
173
|
+
this.newUserPromptOccurredAts = prompts.map((prompt) => prompt.occurredAt);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private result(input: { available: boolean; changed: boolean; reset: boolean }): IncrementalTranscriptPollResult {
|
|
178
|
+
return {
|
|
179
|
+
...input,
|
|
180
|
+
steps: this.accumulator.steps,
|
|
181
|
+
transcriptLooksComplete: this.accumulator.transcriptLooksComplete,
|
|
182
|
+
...(this.accumulator.promptId ? { promptId: this.accumulator.promptId } : {}),
|
|
183
|
+
userPrompts: this.newUserPrompts,
|
|
184
|
+
userPromptOccurredAts: this.newUserPromptOccurredAts,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sameIdentity(a: ClaudeTranscriptFileIdentity, b: ClaudeTranscriptFileIdentity): boolean {
|
|
190
|
+
return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const sameClaudeTranscriptFileIdentityForTests = sameIdentity;
|