context-mode 1.0.165 → 1.0.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +6 -4
- package/build/adapters/codex/usage.d.ts +107 -0
- package/build/adapters/codex/usage.js +227 -0
- package/build/adapters/gemini-cli/hooks.d.ts +7 -1
- package/build/adapters/gemini-cli/hooks.js +9 -1
- package/build/adapters/gemini-cli/index.js +11 -0
- package/build/adapters/kimi/paths.d.ts +20 -0
- package/build/adapters/kimi/paths.js +41 -1
- package/build/adapters/kimi/usage.d.ts +82 -0
- package/build/adapters/kimi/usage.js +217 -0
- package/build/adapters/omp/plugin.d.ts +6 -0
- package/build/adapters/omp/plugin.js +87 -2
- package/build/adapters/omp/usage.d.ts +49 -0
- package/build/adapters/omp/usage.js +110 -0
- package/build/adapters/openclaw/plugin.d.ts +10 -0
- package/build/adapters/openclaw/plugin.js +57 -0
- package/build/adapters/openclaw/usage.d.ts +34 -0
- package/build/adapters/openclaw/usage.js +52 -0
- package/build/adapters/opencode/plugin.d.ts +17 -0
- package/build/adapters/opencode/plugin.js +40 -1
- package/build/adapters/pi/extension.js +61 -10
- package/build/adapters/pi/mcp-bridge.d.ts +78 -1
- package/build/adapters/pi/mcp-bridge.js +105 -17
- package/build/adapters/qwen-code/index.js +23 -1
- package/build/adapters/qwen-code/usage.d.ts +90 -0
- package/build/adapters/qwen-code/usage.js +222 -0
- package/build/lifecycle.d.ts +10 -0
- package/build/lifecycle.js +16 -1
- package/build/session/db.d.ts +11 -0
- package/build/session/db.js +33 -0
- package/build/session/extract.d.ts +208 -0
- package/build/session/extract.js +670 -43
- package/build/session/model-prices.json +429 -0
- package/build/session/pricing.d.ts +64 -0
- package/build/session/pricing.js +151 -0
- package/cli.bundle.mjs +84 -84
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/configs/gemini-cli/settings.json +11 -0
- package/hooks/codex/stop.mjs +91 -4
- package/hooks/gemini-cli/aftermodel.mjs +70 -0
- package/hooks/kimi/stop.mjs +74 -3
- package/hooks/qwen-code/platform.mjs +1 -0
- package/hooks/qwen-code/stop.mjs +168 -0
- package/hooks/session-db.bundle.mjs +7 -7
- package/hooks/session-extract.bundle.mjs +3 -2
- package/hooks/session-loaders.mjs +9 -1
- package/hooks/stop.mjs +35 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +107 -107
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/openclaw/usage — per-turn token + cost capture handler.
|
|
3
|
+
*
|
|
4
|
+
* openclaw emits a first-class `model.usage` diagnostic event once per turn
|
|
5
|
+
* (`DiagnosticUsageEvent`, refs/platforms/openclaw/src/infra/diagnostic-events.ts:18-47),
|
|
6
|
+
* carrying the full usage breakdown {input, output, cacheRead, cacheWrite} plus
|
|
7
|
+
* a PRE-COMPUTED `costUsd` (estimateUsageCost, agent-runner.ts:1995). Consumers
|
|
8
|
+
* subscribe via `onDiagnosticEvent(listener)` (diagnostic-events.ts:1156) — the
|
|
9
|
+
* exact bus the first-party diagnostics-otel / diagnostics-prometheus extensions
|
|
10
|
+
* read.
|
|
11
|
+
*
|
|
12
|
+
* This module is the parse→build→insert handler the plugin's diagnostic-event
|
|
13
|
+
* listener invokes. It is deliberately decoupled from the openclaw plugin SDK so
|
|
14
|
+
* it stays unit-testable: the caller passes the raw payload and an `insert`
|
|
15
|
+
* callback (the plugin hands it `db.insertEvent`-bound-to-sessionId). The handler
|
|
16
|
+
* never throws — a usage-capture failure must never break the agent turn.
|
|
17
|
+
*
|
|
18
|
+
* Capture surface: the diagnostic-event bus, NOT the tool-call hook. The native
|
|
19
|
+
* before_tool_call / after_tool_call relay carries only approval/policy data and
|
|
20
|
+
* NO token usage (matrix §4) — so usage cannot be captured from after_tool_call.
|
|
21
|
+
*/
|
|
22
|
+
import { parseOpenclawUsage, buildAgentUsageEvent } from "../../session/extract.js";
|
|
23
|
+
/**
|
|
24
|
+
* Handle one openclaw `model.usage` diagnostic payload: parse the per-turn usage
|
|
25
|
+
* (NOT lastCallUsage), build the structured `agent_usage` event with openclaw's
|
|
26
|
+
* native `costUsd` (preferred over the pricing catalog), and insert it.
|
|
27
|
+
*
|
|
28
|
+
* Returns the inserted event (for tests / callers that want to forward) or null
|
|
29
|
+
* when the payload is not a usage event, carries no usage, or sums to zero.
|
|
30
|
+
* Best-effort: swallows any insert failure.
|
|
31
|
+
*/
|
|
32
|
+
export function handleOpenclawUsageEvent(payload, insert) {
|
|
33
|
+
// parseOpenclawUsage maps cacheWrite→cache_creation_tokens,
|
|
34
|
+
// cacheRead→cache_read_tokens, costUsd→native_cost_usd, and reads ONLY the
|
|
35
|
+
// per-turn `usage` total — never the lastCallUsage delta.
|
|
36
|
+
const counts = parseOpenclawUsage(payload);
|
|
37
|
+
if (!counts)
|
|
38
|
+
return null;
|
|
39
|
+
// native_cost_usd (openclaw's pre-computed costUsd) is preferred over the
|
|
40
|
+
// catalog inside buildAgentUsageEvent.
|
|
41
|
+
const event = buildAgentUsageEvent(counts);
|
|
42
|
+
if (!event)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
insert(event);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Usage capture must never break the agent turn.
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return event;
|
|
52
|
+
}
|
|
@@ -88,6 +88,22 @@ interface AfterHookOutput {
|
|
|
88
88
|
output: string;
|
|
89
89
|
metadata: any;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* OpenCode generic bus `event` hook — single parameter.
|
|
93
|
+
* The plugin SDK delivers every bus Event here (refs/platforms/opencode/
|
|
94
|
+
* packages/plugin/src/index.ts:224). We narrow to `message.updated`, whose
|
|
95
|
+
* `properties.info` is the full assistant Message carrying tokens/cost/modelID.
|
|
96
|
+
*/
|
|
97
|
+
interface EventHookInput {
|
|
98
|
+
event?: {
|
|
99
|
+
type?: string;
|
|
100
|
+
properties?: {
|
|
101
|
+
info?: {
|
|
102
|
+
sessionID?: string;
|
|
103
|
+
} & Record<string, unknown>;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
}
|
|
91
107
|
/** OpenCode experimental.session.compacting — first parameter */
|
|
92
108
|
interface CompactingHookInput {
|
|
93
109
|
sessionID: string;
|
|
@@ -154,6 +170,7 @@ declare function createContextModePlugin(ctx: PluginContext): Promise<{
|
|
|
154
170
|
tool: Record<string, NativeToolDefinition>;
|
|
155
171
|
"tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
|
|
156
172
|
"tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
|
|
173
|
+
event: (input: EventHookInput) => Promise<void>;
|
|
157
174
|
"chat.message": (input: ChatMessageHookInput, output: ChatMessageHookOutput) => Promise<void>;
|
|
158
175
|
"experimental.session.compacting": (input: CompactingHookInput, output: CompactingHookOutput) => Promise<string>;
|
|
159
176
|
"experimental.chat.system.transform": (input: SystemTransformHookInput, output: SystemTransformHookOutput) => Promise<void>;
|
|
@@ -24,7 +24,7 @@ import { dirname, resolve, join } from "node:path";
|
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
26
26
|
import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
|
|
27
|
-
import { extractEvents, extractUserEvents } from "../../session/extract.js";
|
|
27
|
+
import { extractEvents, extractUserEvents, parseOpencodeUsage, buildAgentUsageEvent } from "../../session/extract.js";
|
|
28
28
|
import { buildResumeSnapshot } from "../../session/snapshot.js";
|
|
29
29
|
import { OpenCodeAdapter } from "./index.js";
|
|
30
30
|
import { PLATFORM_ENV_VARS } from "../detect.js";
|
|
@@ -346,6 +346,45 @@ async function createContextModePlugin(ctx) {
|
|
|
346
346
|
// Silent — session capture must never break the tool call
|
|
347
347
|
}
|
|
348
348
|
},
|
|
349
|
+
// ── event: per-turn token + cost capture (paid-observability) ───
|
|
350
|
+
// The generic bus `event` hook (refs/platforms/opencode/packages/plugin/
|
|
351
|
+
// src/index.ts:224) delivers every Event; we filter `message.updated`
|
|
352
|
+
// (published on each assistant-message update incl. step-finish —
|
|
353
|
+
// session.ts:673) and read tokens/cost/modelID off properties.info
|
|
354
|
+
// (assistant filter via role; refs stream.transport.ts:214-216).
|
|
355
|
+
//
|
|
356
|
+
// CAVEAT (refs processor.ts:717-718): message-level `.tokens` is the LAST
|
|
357
|
+
// step's snapshot (overwritten per step-finish), while `.cost` is
|
|
358
|
+
// cumulative for the turn. parseOpencodeUsage passes `.cost` through as
|
|
359
|
+
// native_cost_usd so the billed $ stays exact despite the token snapshot
|
|
360
|
+
// being last-step only. `message.updated` fires multiple times per turn;
|
|
361
|
+
// because tokens are a terminal snapshot and cost is cumulative, the last
|
|
362
|
+
// event for a message carries the final figures — re-emitting on each
|
|
363
|
+
// update is idempotent at the cost column and merely refreshes the
|
|
364
|
+
// last-step token telemetry. db.insertEvent both persists locally AND
|
|
365
|
+
// forwards to the platform (the TS-plugin equivalent of the .mjs
|
|
366
|
+
// attributeAndInsertEvents path).
|
|
367
|
+
event: async (input) => {
|
|
368
|
+
try {
|
|
369
|
+
const ev = input?.event;
|
|
370
|
+
if (!ev || ev.type !== "message.updated")
|
|
371
|
+
return;
|
|
372
|
+
const sessionId = ev.properties?.info?.sessionID;
|
|
373
|
+
if (!sessionId || typeof sessionId !== "string")
|
|
374
|
+
return;
|
|
375
|
+
const counts = parseOpencodeUsage(ev);
|
|
376
|
+
if (!counts)
|
|
377
|
+
return;
|
|
378
|
+
const usageEvent = buildAgentUsageEvent(counts);
|
|
379
|
+
if (!usageEvent)
|
|
380
|
+
return;
|
|
381
|
+
db.ensureSession(sessionId, projectDir);
|
|
382
|
+
db.insertEvent(sessionId, usageEvent, "MessageUpdated");
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// Silent — usage capture must never break the session.
|
|
386
|
+
}
|
|
387
|
+
},
|
|
349
388
|
// ── chat.message: User-prompt capture (OC-2 / Z2) ───
|
|
350
389
|
// SDK signature verified at refs/platforms/opencode/packages/plugin/src/
|
|
351
390
|
// index.ts:233. Orchestrator reference at refs/plugin-examples/opencode/
|
|
@@ -16,9 +16,9 @@ import { homedir } from "node:os";
|
|
|
16
16
|
import { join, resolve, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
|
|
19
|
-
import { extractEvents, extractUserEvents } from "../../session/extract.js";
|
|
19
|
+
import { extractEvents, extractUserEvents, parsePiUsage, buildAgentUsageEvent } from "../../session/extract.js";
|
|
20
20
|
import { buildResumeSnapshot } from "../../session/snapshot.js";
|
|
21
|
-
import { bootstrapMCPTools } from "./mcp-bridge.js";
|
|
21
|
+
import { bootstrapMCPTools, makeBridgeDiag, isForegroundSession } from "./mcp-bridge.js";
|
|
22
22
|
import { PiAdapter } from "./index.js";
|
|
23
23
|
// ── Pi Tool Name Mapping ─────────────────────────────────
|
|
24
24
|
// Pi uses lowercase; shared extractors expect PascalCase (Claude Code convention).
|
|
@@ -298,9 +298,9 @@ function handleCommandText(text, ctx) {
|
|
|
298
298
|
// print-mode subagents (`pi --mode json -p --no-session`). We start and await
|
|
299
299
|
// the bridge from that hook so ctx_* tools are present before Pi snapshots the
|
|
300
300
|
// tool registry, while CLI-only commands never spawn a bridge at all.
|
|
301
|
-
function startPiMCPBridge(pi, serverBundle, shouldKeepHandle) {
|
|
301
|
+
function startPiMCPBridge(pi, serverBundle, shouldKeepHandle, foreground) {
|
|
302
302
|
if (existsSync(serverBundle)) {
|
|
303
|
-
_mcpBridgeReady = bootstrapMCPTools(pi, serverBundle).then((handle) => {
|
|
303
|
+
_mcpBridgeReady = bootstrapMCPTools(pi, serverBundle, { foreground }).then((handle) => {
|
|
304
304
|
if (shouldKeepHandle()) {
|
|
305
305
|
_mcpBridge = handle;
|
|
306
306
|
}
|
|
@@ -319,8 +319,9 @@ function startPiMCPBridge(pi, serverBundle, shouldKeepHandle) {
|
|
|
319
319
|
if (!shouldKeepHandle())
|
|
320
320
|
return;
|
|
321
321
|
const msg = err instanceof Error ? err.message : String(err);
|
|
322
|
-
|
|
323
|
-
|
|
322
|
+
// #868: route to Pi's file logger, never process.stderr (raw-mode TUI).
|
|
323
|
+
makeBridgeDiag(pi)(`[context-mode] WARNING: failed to bridge MCP tools to Pi (${msg}). ` +
|
|
324
|
+
`ctx_* tools will not be callable from this session.`);
|
|
324
325
|
});
|
|
325
326
|
}
|
|
326
327
|
else {
|
|
@@ -382,12 +383,12 @@ export default function piExtension(pi) {
|
|
|
382
383
|
const serverBundle = resolve(pluginRoot, "server.bundle.mjs");
|
|
383
384
|
let mcpBridgeStarted = false;
|
|
384
385
|
let mcpBridgeGeneration = 0;
|
|
385
|
-
const ensureMCPBridge = () => {
|
|
386
|
+
const ensureMCPBridge = (foreground) => {
|
|
386
387
|
if (mcpBridgeStarted)
|
|
387
388
|
return _mcpBridgeReady;
|
|
388
389
|
mcpBridgeStarted = true;
|
|
389
390
|
const generation = ++mcpBridgeGeneration;
|
|
390
|
-
return startPiMCPBridge(pi, serverBundle, () => mcpBridgeStarted && mcpBridgeGeneration === generation);
|
|
391
|
+
return startPiMCPBridge(pi, serverBundle, () => mcpBridgeStarted && mcpBridgeGeneration === generation, foreground);
|
|
391
392
|
};
|
|
392
393
|
// Issue #545 — Pi workspace resolver. PI_CONFIG_DIR is Pi's CONFIG dir
|
|
393
394
|
// (~/.pi), NOT the user's workspace; using it as the project anchor
|
|
@@ -527,7 +528,7 @@ export default function piExtension(pi) {
|
|
|
527
528
|
}
|
|
528
529
|
});
|
|
529
530
|
// ── 4. before_agent_start — Routing + active_memory + resume injection ─
|
|
530
|
-
pi.on("before_agent_start", async (event) => {
|
|
531
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
531
532
|
try {
|
|
532
533
|
_pendingContext = ""; // Reset — will be filled below if events exist
|
|
533
534
|
// Lazily start and await the MCP bridge only when Pi is about to
|
|
@@ -538,7 +539,24 @@ export default function piExtension(pi) {
|
|
|
538
539
|
// here ensures ctx_* tools are registered before Pi snapshots the tool
|
|
539
540
|
// registry for the model call. Resolves on bootstrap failure too — the
|
|
540
541
|
// bridge is best-effort.
|
|
541
|
-
|
|
542
|
+
//
|
|
543
|
+
// #868: the FOREGROUND interactive session's bridge child is spawned with
|
|
544
|
+
// the #854 idle reaper disabled (via ctx.hasUI), so a multi-minute human
|
|
545
|
+
// pause never drops its ctx_* tools. Subagents (hasUI:false) keep the
|
|
546
|
+
// reaper so abandoned children can't accumulate (#854).
|
|
547
|
+
//
|
|
548
|
+
// INVARIANT — deciding foreground on the FIRST before_agent_start is safe
|
|
549
|
+
// even though the bridge spawns single-flight (first-wins, no sticky
|
|
550
|
+
// latch): Pi wires the interactive uiContext inside `mode.init()`, which
|
|
551
|
+
// main.ts AWAITS before dispatching the first prompt — and
|
|
552
|
+
// before_agent_start is emitted only from the per-turn prompt path. So the
|
|
553
|
+
// foreground session's first hook ALWAYS observes hasUI:true; subagents are
|
|
554
|
+
// provably hasUI:false. There is no early-init window where the foreground
|
|
555
|
+
// transiently reads hasUI:false (that window is scoped to a separate,
|
|
556
|
+
// buffered credential event). Do NOT add a latch here — it would guard an
|
|
557
|
+
// unreachable state. (Verified against oh-my-pi: main.ts init→prompt order,
|
|
558
|
+
// interactive-mode.ts uiContext wiring, executor.ts subagent hasUI:false.)
|
|
559
|
+
await ensureMCPBridge(isForegroundSession(ctx));
|
|
542
560
|
if (!_sessionId)
|
|
543
561
|
return;
|
|
544
562
|
const prompt = String(event?.prompt ?? "");
|
|
@@ -687,6 +705,39 @@ export default function piExtension(pi) {
|
|
|
687
705
|
// best effort — never break provider response
|
|
688
706
|
}
|
|
689
707
|
});
|
|
708
|
+
// ── 4c. turn_end — per-turn token + native-USD cost capture ───
|
|
709
|
+
//
|
|
710
|
+
// Pi delivers per-turn usage on TurnEndEvent.message (an AssistantMessage):
|
|
711
|
+
// usage.{input,output,cacheRead,cacheWrite} + native usage.cost.total in USD,
|
|
712
|
+
// with model on .model. Usage is per-turn incremental, so each turn_end maps
|
|
713
|
+
// to exactly one structured `agent_usage` (category "cost") event — the same
|
|
714
|
+
// shape the Claude Code Stop path emits via buildAgentUsageEvent. We pass
|
|
715
|
+
// Pi's native cost as native_cost_usd so the builder trusts the source over
|
|
716
|
+
// the local price table (cost_confidence: HIGH — no price-table maintenance).
|
|
717
|
+
//
|
|
718
|
+
// Refs: adapter-matrix/pi.md @320261f — shared-events.ts:204-209 (TurnEndEvent),
|
|
719
|
+
// ai/src/types.ts:510/521 (model/usage), catalog/src/types.ts:100-145 (Usage).
|
|
720
|
+
// Best-effort: parse is null-safe and the handler never throws (a telemetry
|
|
721
|
+
// forwarder must never break the agent turn).
|
|
722
|
+
pi.on("turn_end", (event) => {
|
|
723
|
+
try {
|
|
724
|
+
if (!_sessionId)
|
|
725
|
+
return;
|
|
726
|
+
const counts = parsePiUsage(event);
|
|
727
|
+
if (!counts)
|
|
728
|
+
return; // non-assistant turn or all-zero usage
|
|
729
|
+
const ev = buildAgentUsageEvent(counts);
|
|
730
|
+
if (!ev)
|
|
731
|
+
return;
|
|
732
|
+
// db.insertEvent is the extension-side analog of the .mjs hooks'
|
|
733
|
+
// attributeAndInsertEvents (insert + project attribution). The MCP
|
|
734
|
+
// server forwards persisted agent_usage events to the platform.
|
|
735
|
+
db.insertEvent(_sessionId, ev, "Stop", _attribution);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
// best effort — never break the agent turn
|
|
739
|
+
}
|
|
740
|
+
});
|
|
690
741
|
// ── 5. session_before_compact — Build resume snapshot ──
|
|
691
742
|
pi.on("session_before_compact", () => {
|
|
692
743
|
try {
|
|
@@ -78,6 +78,12 @@ export declare class MCPStdioClient {
|
|
|
78
78
|
private readonly serverScript;
|
|
79
79
|
private readonly env;
|
|
80
80
|
private readonly runtimeOverride;
|
|
81
|
+
/**
|
|
82
|
+
* TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
|
|
83
|
+
* no-op so direct callers (skippedBridge, tests) never leak to the
|
|
84
|
+
* terminal; bootstrapMCPTools wires this to the Pi host's file logger.
|
|
85
|
+
*/
|
|
86
|
+
private readonly diag;
|
|
81
87
|
private child;
|
|
82
88
|
private requestId;
|
|
83
89
|
private readonly pending;
|
|
@@ -99,7 +105,13 @@ export declare class MCPStdioClient {
|
|
|
99
105
|
* without needing to attach a process-tree probe.
|
|
100
106
|
*/
|
|
101
107
|
_spawnEnv: NodeJS.ProcessEnv | null;
|
|
102
|
-
constructor(serverScript: string, env?: NodeJS.ProcessEnv, runtimeOverride?: string | null
|
|
108
|
+
constructor(serverScript: string, env?: NodeJS.ProcessEnv, runtimeOverride?: string | null,
|
|
109
|
+
/**
|
|
110
|
+
* TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
|
|
111
|
+
* no-op so direct callers (skippedBridge, tests) never leak to the
|
|
112
|
+
* terminal; bootstrapMCPTools wires this to the Pi host's file logger.
|
|
113
|
+
*/
|
|
114
|
+
diag?: BridgeDiag);
|
|
103
115
|
/** Spawn the MCP child. Idempotent. */
|
|
104
116
|
start(): void;
|
|
105
117
|
private onExit;
|
|
@@ -161,7 +173,64 @@ export interface PiToolRegistration {
|
|
|
161
173
|
}
|
|
162
174
|
export interface PiLikeAPI {
|
|
163
175
|
registerTool: (tool: PiToolRegistration) => void;
|
|
176
|
+
/**
|
|
177
|
+
* Pi's rotating file logger (`~/.omp/logs/`). Pi runs a raw-mode TUI that
|
|
178
|
+
* owns the terminal, so an in-process extension MUST NOT write to
|
|
179
|
+
* process.stdout/stderr — any console write is rendered straight into the
|
|
180
|
+
* editor input box and blocks typing (#868, confirmed against
|
|
181
|
+
* oh-my-pi tui/terminal.ts raw-mode + docs/skills/authoring-extensions.md:
|
|
182
|
+
* "nothing is written to the console, which would corrupt the TUI"). All
|
|
183
|
+
* bridge diagnostics route here instead. Optional: absent in tests / minimal
|
|
184
|
+
* hosts, in which case diagnostics are dropped — we NEVER fall back to the
|
|
185
|
+
* terminal.
|
|
186
|
+
*/
|
|
187
|
+
logger?: {
|
|
188
|
+
debug?: (message: string, context?: Record<string, unknown>) => void;
|
|
189
|
+
info?: (message: string, context?: Record<string, unknown>) => void;
|
|
190
|
+
warn?: (message: string, context?: Record<string, unknown>) => void;
|
|
191
|
+
error?: (message: string, context?: Record<string, unknown>) => void;
|
|
192
|
+
};
|
|
164
193
|
}
|
|
194
|
+
/** TUI-safe diagnostics sink: routes to Pi's file logger, never the terminal. */
|
|
195
|
+
export type BridgeDiag = (line: string, level?: "warn" | "debug") => void;
|
|
196
|
+
/**
|
|
197
|
+
* Build a {@link BridgeDiag} bound to a Pi host's file logger (#868). Writing to
|
|
198
|
+
* process.stderr from inside Pi's raw-mode TUI corrupts the editor, so every
|
|
199
|
+
* bridge diagnostic — the forwarded MCP child stderr included — goes to
|
|
200
|
+
* `pi.logger` instead. When no logger is reachable (tests, non-Pi hosts) the
|
|
201
|
+
* line is dropped; we never touch the terminal as a fallback.
|
|
202
|
+
*/
|
|
203
|
+
export declare function makeBridgeDiag(pi: PiLikeAPI | null | undefined): BridgeDiag;
|
|
204
|
+
/**
|
|
205
|
+
* Split a chunk of forwarded child output into lines without a regex (the repo
|
|
206
|
+
* forbids regex in source). Trailing `\r` is stripped so CRLF traces stay clean
|
|
207
|
+
* in the log; the final partial line (no trailing newline) is preserved.
|
|
208
|
+
*/
|
|
209
|
+
export declare function splitDiagLines(text: string): string[];
|
|
210
|
+
/**
|
|
211
|
+
* #868: is this the FOREGROUND interactive Pi session (vs a subagent / print /
|
|
212
|
+
* RPC session)? Pi passes an ExtensionContext as the 2nd arg to
|
|
213
|
+
* `before_agent_start`; `ctx.hasUI === true` only for the interactive session
|
|
214
|
+
* with a real UI attached (refs oh-my-pi runner.ts:330-331), while subagents
|
|
215
|
+
* are provably `hasUI: false` (refs executor.ts:2052). Fail-safe: treat anything
|
|
216
|
+
* that is NOT an explicit `hasUI === false` as foreground, so an
|
|
217
|
+
* ambiguous/absent ctx keeps the session's bridge ALIVE rather than risking the
|
|
218
|
+
* #868 idle-drop. Mis-classifying an abandoned non-interactive child as
|
|
219
|
+
* foreground only costs one lingering child until parent-death/ session_shutdown
|
|
220
|
+
* reaps it; the opposite error re-drops the user's tools mid-session.
|
|
221
|
+
*/
|
|
222
|
+
export declare function isForegroundSession(ctx: unknown): boolean;
|
|
223
|
+
/**
|
|
224
|
+
* #868: derive the bridge child's spawn env for a session kind. The FOREGROUND
|
|
225
|
+
* interactive session's child must never be idle-reaped — a multi-minute human
|
|
226
|
+
* pause should not drop its ctx_* tools — so we disable the #854 reaper for it
|
|
227
|
+
* via `CONTEXT_MODE_BRIDGE_IDLE_MS=0` (lifecycle.ts honors 0 → reaper not armed).
|
|
228
|
+
* Sub-context / non-interactive children keep the default reaper so abandoned
|
|
229
|
+
* children still can't accumulate (#854). The foreground child is still reaped
|
|
230
|
+
* on actual parent death by the ppid/signal watchdog (#311/#388) — only the
|
|
231
|
+
* idle-time path is disabled. Pure; does not mutate the input env.
|
|
232
|
+
*/
|
|
233
|
+
export declare function foregroundBridgeEnv(baseEnv: NodeJS.ProcessEnv, foreground: boolean): NodeJS.ProcessEnv;
|
|
165
234
|
/** Result of bootstrapping the bridge. */
|
|
166
235
|
export interface BridgeHandle {
|
|
167
236
|
/** Names of tools registered with Pi (for diagnostics / tests). */
|
|
@@ -186,6 +255,14 @@ export interface BootstrapOptions {
|
|
|
186
255
|
env?: NodeJS.ProcessEnv;
|
|
187
256
|
/** DI hook for tests: override the runtime resolver entirely. */
|
|
188
257
|
_resolveJsRuntime?: () => string | null;
|
|
258
|
+
/**
|
|
259
|
+
* #868: true for the foreground interactive Pi session → spawn the child with
|
|
260
|
+
* the #854 idle reaper DISABLED so a human pause never drops its tools.
|
|
261
|
+
* Defaults to false (keep the reaper) for any non-foreground / unspecified
|
|
262
|
+
* caller; the pi extension resolves this from `ctx.hasUI` via
|
|
263
|
+
* {@link isForegroundSession}.
|
|
264
|
+
*/
|
|
265
|
+
foreground?: boolean;
|
|
189
266
|
}
|
|
190
267
|
export declare function bootstrapMCPTools(pi: PiLikeAPI, serverScript: string, options?: BootstrapOptions): Promise<BridgeHandle>;
|
|
191
268
|
export {};
|
|
@@ -323,6 +323,7 @@ export class MCPStdioClient {
|
|
|
323
323
|
serverScript;
|
|
324
324
|
env;
|
|
325
325
|
runtimeOverride;
|
|
326
|
+
diag;
|
|
326
327
|
child = null;
|
|
327
328
|
requestId = 0;
|
|
328
329
|
pending = new Map();
|
|
@@ -344,10 +345,17 @@ export class MCPStdioClient {
|
|
|
344
345
|
* without needing to attach a process-tree probe.
|
|
345
346
|
*/
|
|
346
347
|
_spawnEnv = null;
|
|
347
|
-
constructor(serverScript, env = process.env, runtimeOverride = null
|
|
348
|
+
constructor(serverScript, env = process.env, runtimeOverride = null,
|
|
349
|
+
/**
|
|
350
|
+
* TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
|
|
351
|
+
* no-op so direct callers (skippedBridge, tests) never leak to the
|
|
352
|
+
* terminal; bootstrapMCPTools wires this to the Pi host's file logger.
|
|
353
|
+
*/
|
|
354
|
+
diag = () => { }) {
|
|
348
355
|
this.serverScript = serverScript;
|
|
349
356
|
this.env = env;
|
|
350
357
|
this.runtimeOverride = runtimeOverride;
|
|
358
|
+
this.diag = diag;
|
|
351
359
|
}
|
|
352
360
|
/** Spawn the MCP child. Idempotent. */
|
|
353
361
|
start() {
|
|
@@ -434,21 +442,23 @@ export class MCPStdioClient {
|
|
|
434
442
|
this.child = spawn(runtime, [this.serverScript], {
|
|
435
443
|
// Pipe stderr (#472 round-3): swallowing it via "ignore" hides
|
|
436
444
|
// server crash diagnostics — the user only saw "ctx_* tools will
|
|
437
|
-
// not be callable" with no clue WHY.
|
|
438
|
-
//
|
|
445
|
+
// not be callable" with no clue WHY. We capture it so the diagnostic
|
|
446
|
+
// is preserved, but route it through `diag` (Pi's file logger), NOT
|
|
447
|
+
// process.stderr — Pi's raw-mode TUI owns the terminal and any console
|
|
448
|
+
// write is rendered into the editor input box, blocking typing (#868).
|
|
439
449
|
stdio: ["pipe", "pipe", "pipe"],
|
|
440
450
|
env: childEnv,
|
|
441
451
|
});
|
|
442
452
|
this.child.stdout?.on("data", (chunk) => this.onData(chunk));
|
|
443
453
|
this.child.stderr?.on("data", (chunk) => {
|
|
444
454
|
const text = chunk.toString("utf-8");
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
455
|
+
// Forward each non-empty line, [mcp-bridge]-prefixed so it stays
|
|
456
|
+
// grep-friendly in ~/.omp/logs. debug level: this is mostly routine
|
|
457
|
+
// child chatter (e.g. the #854 idle-reaper notice), not an alert.
|
|
458
|
+
for (const line of splitDiagLines(text)) {
|
|
459
|
+
if (line !== "")
|
|
460
|
+
this.diag(`[mcp-bridge] ${line}`, "debug");
|
|
461
|
+
}
|
|
452
462
|
});
|
|
453
463
|
this.child.on("exit", () => this.onExit());
|
|
454
464
|
this.child.on("error", () => this.onExit());
|
|
@@ -690,6 +700,78 @@ export class MCPStdioClient {
|
|
|
690
700
|
this.exited = true;
|
|
691
701
|
}
|
|
692
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Build a {@link BridgeDiag} bound to a Pi host's file logger (#868). Writing to
|
|
705
|
+
* process.stderr from inside Pi's raw-mode TUI corrupts the editor, so every
|
|
706
|
+
* bridge diagnostic — the forwarded MCP child stderr included — goes to
|
|
707
|
+
* `pi.logger` instead. When no logger is reachable (tests, non-Pi hosts) the
|
|
708
|
+
* line is dropped; we never touch the terminal as a fallback.
|
|
709
|
+
*/
|
|
710
|
+
export function makeBridgeDiag(pi) {
|
|
711
|
+
const logger = pi?.logger;
|
|
712
|
+
return (line, level = "warn") => {
|
|
713
|
+
try {
|
|
714
|
+
const fn = level === "debug" ? logger?.debug : logger?.warn;
|
|
715
|
+
if (typeof fn === "function")
|
|
716
|
+
fn(line);
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
/* never throw from diagnostics — and never write to the TUI terminal */
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Split a chunk of forwarded child output into lines without a regex (the repo
|
|
725
|
+
* forbids regex in source). Trailing `\r` is stripped so CRLF traces stay clean
|
|
726
|
+
* in the log; the final partial line (no trailing newline) is preserved.
|
|
727
|
+
*/
|
|
728
|
+
export function splitDiagLines(text) {
|
|
729
|
+
const lines = [];
|
|
730
|
+
let start = 0;
|
|
731
|
+
for (let i = 0; i < text.length; i++) {
|
|
732
|
+
if (text[i] === "\n") {
|
|
733
|
+
let end = i;
|
|
734
|
+
if (end > start && text[end - 1] === "\r")
|
|
735
|
+
end--;
|
|
736
|
+
lines.push(text.slice(start, end));
|
|
737
|
+
start = i + 1;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (start < text.length)
|
|
741
|
+
lines.push(text.slice(start));
|
|
742
|
+
return lines;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* #868: is this the FOREGROUND interactive Pi session (vs a subagent / print /
|
|
746
|
+
* RPC session)? Pi passes an ExtensionContext as the 2nd arg to
|
|
747
|
+
* `before_agent_start`; `ctx.hasUI === true` only for the interactive session
|
|
748
|
+
* with a real UI attached (refs oh-my-pi runner.ts:330-331), while subagents
|
|
749
|
+
* are provably `hasUI: false` (refs executor.ts:2052). Fail-safe: treat anything
|
|
750
|
+
* that is NOT an explicit `hasUI === false` as foreground, so an
|
|
751
|
+
* ambiguous/absent ctx keeps the session's bridge ALIVE rather than risking the
|
|
752
|
+
* #868 idle-drop. Mis-classifying an abandoned non-interactive child as
|
|
753
|
+
* foreground only costs one lingering child until parent-death/ session_shutdown
|
|
754
|
+
* reaps it; the opposite error re-drops the user's tools mid-session.
|
|
755
|
+
*/
|
|
756
|
+
export function isForegroundSession(ctx) {
|
|
757
|
+
const hasUI = ctx?.hasUI;
|
|
758
|
+
return hasUI !== false;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* #868: derive the bridge child's spawn env for a session kind. The FOREGROUND
|
|
762
|
+
* interactive session's child must never be idle-reaped — a multi-minute human
|
|
763
|
+
* pause should not drop its ctx_* tools — so we disable the #854 reaper for it
|
|
764
|
+
* via `CONTEXT_MODE_BRIDGE_IDLE_MS=0` (lifecycle.ts honors 0 → reaper not armed).
|
|
765
|
+
* Sub-context / non-interactive children keep the default reaper so abandoned
|
|
766
|
+
* children still can't accumulate (#854). The foreground child is still reaped
|
|
767
|
+
* on actual parent death by the ppid/signal watchdog (#311/#388) — only the
|
|
768
|
+
* idle-time path is disabled. Pure; does not mutate the input env.
|
|
769
|
+
*/
|
|
770
|
+
export function foregroundBridgeEnv(baseEnv, foreground) {
|
|
771
|
+
if (!foreground)
|
|
772
|
+
return baseEnv;
|
|
773
|
+
return { ...baseEnv, CONTEXT_MODE_BRIDGE_IDLE_MS: "0" };
|
|
774
|
+
}
|
|
693
775
|
/**
|
|
694
776
|
* Empty-but-valid handle returned when bootstrap is skipped (#516).
|
|
695
777
|
* Keeps the shutdown contract intact so callers do not need null checks.
|
|
@@ -705,14 +787,16 @@ function skippedBridge() {
|
|
|
705
787
|
}
|
|
706
788
|
export async function bootstrapMCPTools(pi, serverScript, options = {}) {
|
|
707
789
|
const env = options.env ?? process.env;
|
|
790
|
+
// #868: all bridge diagnostics go to Pi's file logger, never the TUI terminal.
|
|
791
|
+
const diag = makeBridgeDiag(pi);
|
|
708
792
|
// Recursion guard (#516): if an ancestor bridge already incremented
|
|
709
793
|
// the depth counter, refuse to spawn another child — even if the
|
|
710
794
|
// binary-name check would let us through. Catches `node` shims that
|
|
711
795
|
// re-exec Pi and other host swaps that bypass basename detection.
|
|
712
796
|
const depth = Number.parseInt(env[BRIDGE_DEPTH_ENV] ?? "0", 10);
|
|
713
797
|
if (Number.isFinite(depth) && depth > 0) {
|
|
714
|
-
|
|
715
|
-
`indicates recursion (fork-bomb guard, #516). ctx_* tools will not be callable
|
|
798
|
+
diag(`[context-mode] WARNING: skipping MCP bridge — ${BRIDGE_DEPTH_ENV}=${depth} ` +
|
|
799
|
+
`indicates recursion (fork-bomb guard, #516). ctx_* tools will not be callable.`);
|
|
716
800
|
return skippedBridge();
|
|
717
801
|
}
|
|
718
802
|
// Runtime guard (#516): when neither node nor bun is on PATH and the
|
|
@@ -720,11 +804,15 @@ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
|
|
|
720
804
|
// return an empty handle — the rest of the extension keeps working.
|
|
721
805
|
const runtime = (options._resolveJsRuntime ?? resolveJsRuntimeForBridge)();
|
|
722
806
|
if (runtime === null) {
|
|
723
|
-
|
|
724
|
-
`Skipping MCP bridge to avoid fork bomb (#516). ctx_* tools will not be callable
|
|
807
|
+
diag(`[context-mode] WARNING: no JS runtime found (need node or bun on PATH). ` +
|
|
808
|
+
`Skipping MCP bridge to avoid fork bomb (#516). ctx_* tools will not be callable.`);
|
|
725
809
|
return skippedBridge();
|
|
726
810
|
}
|
|
727
|
-
|
|
811
|
+
// #868: the foreground interactive session's child runs with the #854 idle
|
|
812
|
+
// reaper disabled (CONTEXT_MODE_BRIDGE_IDLE_MS=0) so a human pause never drops
|
|
813
|
+
// its tools; sub-context / non-interactive children keep the reaper (#854).
|
|
814
|
+
const spawnEnv = foregroundBridgeEnv(env, options.foreground ?? false);
|
|
815
|
+
const client = new MCPStdioClient(serverScript, spawnEnv, runtime, diag);
|
|
728
816
|
// Retry-on-slow-initialize (#647).
|
|
729
817
|
//
|
|
730
818
|
// Each attempt is independently bounded by DEFAULT_REQUEST_TIMEOUT_MS
|
|
@@ -747,8 +835,8 @@ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
|
|
|
747
835
|
if (attempt === MAX_INIT_RETRIES)
|
|
748
836
|
break;
|
|
749
837
|
const msg = err instanceof Error ? err.message : String(err);
|
|
750
|
-
|
|
751
|
-
`(attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}): ${msg}. Retrying
|
|
838
|
+
diag(`[context-mode] WARNING: MCP bridge initialize failed ` +
|
|
839
|
+
`(attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}): ${msg}. Retrying…`);
|
|
752
840
|
// Reclaim the failed child's fds before respawning. shutdown() is
|
|
753
841
|
// idempotent and bounded by a 5s SIGKILL fallback (#472 round-3),
|
|
754
842
|
// so a child stuck in an uninterruptible syscall cannot block the
|
|
@@ -101,6 +101,19 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
|
|
|
101
101
|
],
|
|
102
102
|
},
|
|
103
103
|
],
|
|
104
|
+
// Stop fires at end-of-turn. The qwen-specific stop hook records a
|
|
105
|
+
// turn_end marker AND captures per-turn token cost by tailing the session
|
|
106
|
+
// chats JSONL (~/.qwen/tmp/<hash>/chats/<sessionId>.jsonl) — usage is not
|
|
107
|
+
// reachable through hook stdin (usage.ts matrix §4). Points at the
|
|
108
|
+
// qwen-code/ hook dir (not the shared root) so it sets the qwen platform.
|
|
109
|
+
Stop: [
|
|
110
|
+
{
|
|
111
|
+
matcher: "",
|
|
112
|
+
hooks: [
|
|
113
|
+
{ type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/qwen-code/stop.mjs`) },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
104
117
|
};
|
|
105
118
|
}
|
|
106
119
|
// ── Settings read/write ────────────────────────────────
|
|
@@ -126,7 +139,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
|
|
|
126
139
|
const results = [];
|
|
127
140
|
const settings = this.readSettings();
|
|
128
141
|
const hooks = (settings?.hooks ?? {});
|
|
129
|
-
for (const hookName of ["PreToolUse", "PostToolUse", "SessionStart", "PreCompact", "UserPromptSubmit"]) {
|
|
142
|
+
for (const hookName of ["PreToolUse", "PostToolUse", "SessionStart", "PreCompact", "UserPromptSubmit", "Stop"]) {
|
|
130
143
|
const configured = Array.isArray(hooks[hookName]) && hooks[hookName].length > 0;
|
|
131
144
|
results.push({
|
|
132
145
|
check: `${hookName} hook`,
|
|
@@ -275,6 +288,15 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
|
|
|
275
288
|
script: "userpromptsubmit.mjs",
|
|
276
289
|
matcher: "",
|
|
277
290
|
},
|
|
291
|
+
{
|
|
292
|
+
// Stop captures per-turn token cost by tailing the session chats JSONL
|
|
293
|
+
// (usage is unreachable through hook stdin). Routes to the qwen-code/
|
|
294
|
+
// hook dir so it sets the qwen platform — keep in sync with
|
|
295
|
+
// generateHookConfig above.
|
|
296
|
+
name: "Stop",
|
|
297
|
+
script: "qwen-code/stop.mjs",
|
|
298
|
+
matcher: "",
|
|
299
|
+
},
|
|
278
300
|
];
|
|
279
301
|
for (const { name, script, matcher } of hookTypes) {
|
|
280
302
|
const entry = {
|