@junghanacs/entwurf 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ACP plugin — pi Context → ACP prompt conversion (S2c).
|
|
2
|
+
//
|
|
3
|
+
// S2c is spawn-per-turn: every streamSimple call spawns a fresh ACP session, so
|
|
4
|
+
// the backend has NO memory of prior turns. Sending only the last user message
|
|
5
|
+
// would silently drop multi-turn history — that is context loss, not a thin
|
|
6
|
+
// substrate. So this flattens the whole pi conversation into ONE text transcript
|
|
7
|
+
// and sends it as a single ACP user prompt block.
|
|
8
|
+
//
|
|
9
|
+
// S2c/S2d boundary (GPT S2c Q2): this is CONVERSATION TRANSCRIPT PASSTHROUGH, not
|
|
10
|
+
// rich-carrier identity injection. Deliberately EXCLUDED here (all S2d):
|
|
11
|
+
// - `context.systemPrompt` — never read into the prompt or `_meta.systemPrompt`
|
|
12
|
+
// (the billing carrier stays absent — NEXT §S2-scout 핀1);
|
|
13
|
+
// - `~/AGENTS.md` / cwd AGENTS / bridge identity narrative;
|
|
14
|
+
// - first-user-message augment + project-context de-dup;
|
|
15
|
+
// - `context.tools` — the ACP child tool surface is the S2b
|
|
16
|
+
// `_meta.claudeCode.options` SSOT, never re-sent here.
|
|
17
|
+
// Structured tool replay is also excluded: tool calls/results render as plain
|
|
18
|
+
// transcript text, never as ACP tool invocations (the child runs its own tools).
|
|
19
|
+
|
|
20
|
+
import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
|
21
|
+
|
|
22
|
+
// MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
|
|
23
|
+
// It is mirrored, not imported: the strip-types deterministic gates load these
|
|
24
|
+
// lib files by their `.ts` source and cannot resolve a cross-sibling VALUE
|
|
25
|
+
// import (`./event-mapper.js`) — the lib modules share TYPES only. The mirror is
|
|
26
|
+
// kept honest behaviorally by check-acp-session-reuse (a drift would leave the
|
|
27
|
+
// `[acp: …]` notices in the captured ACP prompt and fail the gate).
|
|
28
|
+
const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
|
|
29
|
+
|
|
30
|
+
/** An ACP text content block. */
|
|
31
|
+
export interface AcpTextBlock {
|
|
32
|
+
type: "text";
|
|
33
|
+
text: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function textFromUserOrToolContent(content: UserMessage["content"] | ToolResultMessage["content"]): string {
|
|
37
|
+
if (typeof content === "string") return content;
|
|
38
|
+
// Render text verbatim; images are NOT dropped silently — they leave a text
|
|
39
|
+
// marker so the transcript honestly records an attachment the text-only S2c
|
|
40
|
+
// transcript cannot carry (real ACP image passthrough is a later lane).
|
|
41
|
+
return content
|
|
42
|
+
.map((c) => {
|
|
43
|
+
if (c.type === "text") return c.text;
|
|
44
|
+
if (c.type === "image") return `[image omitted: ${c.mimeType ?? "unknown"}]`;
|
|
45
|
+
return "";
|
|
46
|
+
})
|
|
47
|
+
.filter((s) => s !== "")
|
|
48
|
+
.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function textFromAssistantContent(content: AssistantMessage["content"]): string {
|
|
52
|
+
// Assistant text only — thinking is omitted and tool calls are not replayed
|
|
53
|
+
// (the ACP child executes its own tools; replaying structured calls would be
|
|
54
|
+
// a lie). Tool RESULTS still appear via their own toolResult message below.
|
|
55
|
+
return (
|
|
56
|
+
content
|
|
57
|
+
.filter((c): c is { type: "text"; text: string; textSignature?: string } => c.type === "text")
|
|
58
|
+
// Drop S2f lifecycle progress notices (`[acp: …]`): display-only, stamped
|
|
59
|
+
// with LIFECYCLE_NOTICE_SIGNATURE. Replaying them into a `new` rebuild's
|
|
60
|
+
// full transcript would inject bridge-internal chatter into the ACP prompt.
|
|
61
|
+
.filter((c) => c.textSignature !== LIFECYCLE_NOTICE_SIGNATURE)
|
|
62
|
+
.map((c) => c.text)
|
|
63
|
+
.join("")
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Render one pi message as a transcript line, or undefined to skip it. */
|
|
68
|
+
function renderMessage(message: Message): string | undefined {
|
|
69
|
+
switch (message.role) {
|
|
70
|
+
case "user": {
|
|
71
|
+
const text = textFromUserOrToolContent(message.content).trim();
|
|
72
|
+
return text ? `User: ${text}` : undefined;
|
|
73
|
+
}
|
|
74
|
+
case "assistant": {
|
|
75
|
+
const text = textFromAssistantContent(message.content).trim();
|
|
76
|
+
return text ? `Assistant: ${text}` : undefined;
|
|
77
|
+
}
|
|
78
|
+
case "toolResult": {
|
|
79
|
+
const text = textFromUserOrToolContent(message.content).trim();
|
|
80
|
+
const tag = message.isError ? "Tool error" : "Tool result";
|
|
81
|
+
return text ? `${tag} (${message.toolName}): ${text}` : undefined;
|
|
82
|
+
}
|
|
83
|
+
default:
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Flatten a pi Context into a single transcript string. Excludes
|
|
90
|
+
* `context.systemPrompt` and `context.tools` by construction.
|
|
91
|
+
*/
|
|
92
|
+
export function contextTranscript(context: Context): string {
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
for (const message of context.messages) {
|
|
95
|
+
const line = renderMessage(message);
|
|
96
|
+
if (line) lines.push(line);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert a pi Context into the ACP `prompt` array (a single text block holding
|
|
103
|
+
* the flattened transcript). Empty history yields an empty array — the caller
|
|
104
|
+
* decides whether that is a hard error.
|
|
105
|
+
*/
|
|
106
|
+
export function contextToAcpPrompt(context: Context): AcpTextBlock[] {
|
|
107
|
+
const transcript = contextTranscript(context);
|
|
108
|
+
if (!transcript) return [];
|
|
109
|
+
return [{ type: "text", text: transcript }];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ACP session bootstrap path — the input that decides the prompt SCOPE (NEXT
|
|
114
|
+
* §S2-scout 핀4). S2c was spawn-per-turn (always a fresh session), so this stays
|
|
115
|
+
* `"new"` until S2d wires the session store that can actually produce the reuse
|
|
116
|
+
* paths.
|
|
117
|
+
*/
|
|
118
|
+
export type AcpBootstrapPath = "new" | "reuse" | "resume" | "load";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The latest user turn only — the first user message AFTER the last assistant
|
|
122
|
+
* message. Ported from 0.11.0 `index.ts:732 extractPromptBlocks`: taking the
|
|
123
|
+
* FIRST user of the trailing group (not `reverse().find()`) skips the
|
|
124
|
+
* SessionStart hook user-message (`device=…, time_kst=…`) that pi appends AFTER
|
|
125
|
+
* the real prompt. Images leave a text marker (S2c decision — real ACP image
|
|
126
|
+
* passthrough is a later lane), never raw data.
|
|
127
|
+
*
|
|
128
|
+
* This is the prompt scope for a session that ALREADY holds the prior turns
|
|
129
|
+
* (reuse/resume/load): re-sending the whole transcript there would duplicate
|
|
130
|
+
* history the backend already remembers.
|
|
131
|
+
*/
|
|
132
|
+
export function latestUserDelta(context: Context): AcpTextBlock[] {
|
|
133
|
+
let lastAssistantIdx = -1;
|
|
134
|
+
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
135
|
+
if (context.messages[i].role === "assistant") {
|
|
136
|
+
lastAssistantIdx = i;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const latestUser = context.messages.slice(lastAssistantIdx + 1).find((m): m is UserMessage => m.role === "user");
|
|
141
|
+
if (!latestUser) return [];
|
|
142
|
+
// The delta IS the user's actual prompt — preserve its body verbatim (0.11.0
|
|
143
|
+
// extractPromptBlocks sent it near-raw). Only the EMPTINESS test trims, so a
|
|
144
|
+
// whitespace-only turn yields no block.
|
|
145
|
+
const raw = textFromUserOrToolContent(latestUser.content);
|
|
146
|
+
return raw.trim() ? [{ type: "text", text: raw }] : [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the ACP prompt array for a turn, scoping it by bootstrapPath (핀4):
|
|
151
|
+
* - `"new"` (incompatible rebuild included): a fresh ACP session holds NO
|
|
152
|
+
* history, so the whole transcript is the only history carrier (same as the
|
|
153
|
+
* S2c spawn-per-turn path). Delta-only here would lose history on
|
|
154
|
+
* rebuild/compaction/edited-history.
|
|
155
|
+
* - `"reuse" | "resume" | "load"`: the stateful ACP session already holds the
|
|
156
|
+
* prior turns, so send only the latest user delta — the whole transcript
|
|
157
|
+
* would duplicate remembered history.
|
|
158
|
+
*
|
|
159
|
+
* The delta-only SAFETY for resume/load is owned by the caller's
|
|
160
|
+
* `contextMessageSignatures` prefix-compat gate (mismatch → fall back to
|
|
161
|
+
* `"new"` + full transcript); this pure function only splits the scope.
|
|
162
|
+
*/
|
|
163
|
+
export function buildAcpPrompt(context: Context, bootstrapPath: AcpBootstrapPath): AcpTextBlock[] {
|
|
164
|
+
switch (bootstrapPath) {
|
|
165
|
+
case "new":
|
|
166
|
+
return contextToAcpPrompt(context);
|
|
167
|
+
case "reuse":
|
|
168
|
+
case "resume":
|
|
169
|
+
case "load":
|
|
170
|
+
return latestUserDelta(context);
|
|
171
|
+
default:
|
|
172
|
+
// Fail-loud (핀4): a bad/unknown bootstrapPath must CRASH, never fall
|
|
173
|
+
// through to delta-only. A silent delta on a path that should carry the
|
|
174
|
+
// full transcript loses history — fail-OPEN toward the dangerous side.
|
|
175
|
+
throw new Error(`buildAcpPrompt: unknown bootstrapPath ${JSON.stringify(bootstrapPath)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ACP plugin — billing carrier (engraving) loader (S2d-1c).
|
|
2
|
+
//
|
|
3
|
+
// The engraving is the OPERATOR surface for the Claude `_meta.systemPrompt`
|
|
4
|
+
// carrier — short, personal additions an operator wants attached to every ACP
|
|
5
|
+
// session's system prompt. It is NOT the bridge-identity / AGENTS / pi-base
|
|
6
|
+
// narrative: that rich context rides the first-user-message augment (augment.ts)
|
|
7
|
+
// because the system-prompt carrier MUST stay tiny.
|
|
8
|
+
//
|
|
9
|
+
// Why a SMALL but NON-EMPTY default (the v1 memory-containment lever, restored):
|
|
10
|
+
// shipping a non-empty string here makes claude-agent-acp REPLACE its
|
|
11
|
+
// `claude_code` preset with this string (acp-agent.js: string-form
|
|
12
|
+
// `_meta.systemPrompt` → full preset replacement). That replacement strips the
|
|
13
|
+
// preset's auto-memory section, so the ACP model never learns it has a per-session
|
|
14
|
+
// memory store — the containment the operator baseline depends on. An EMPTY
|
|
15
|
+
// carrier keeps the preset and re-leaks auto-memory (the model writes memory/*.md
|
|
16
|
+
// via Write): that regression is exactly what a non-empty default fixes.
|
|
17
|
+
//
|
|
18
|
+
// Billing axis is SIZE, not SHAPE (NEXT §S2-scout 핀1, oracle A): Anthropic
|
|
19
|
+
// subscription billing (Claude Code OAuth, 정액제) reclassifies a call as metered
|
|
20
|
+
// "extra usage" — HTTP 400 for users with no metered balance — when the carrier
|
|
21
|
+
// materially GROWS past the SDK-default size (e.g. by injecting AGENTS.md or the
|
|
22
|
+
// pi base prompt). A tiny placeholder string is shape-deviant yet v1-production-
|
|
23
|
+
// safe, so the rule is keep the carrier SHORT, never "absent". Rich context still
|
|
24
|
+
// rides the first-user-message augment (augment.ts), never this carrier.
|
|
25
|
+
//
|
|
26
|
+
// Stability contract (NEXT oracle C / 핀1): the rendered output MUST be a pure
|
|
27
|
+
// function of (template content on disk, backend, mcpServerNames). No clock /
|
|
28
|
+
// random / env-time. `bridgeConfigSignature` folds this string into its
|
|
29
|
+
// `appendSystemPrompt` slot — if the rendered carrier drifted turn-to-turn, the
|
|
30
|
+
// signature would drift and entwurf would rebuild the ACP session every
|
|
31
|
+
// turn. The default-path source is cached once for exactly this reason, so a
|
|
32
|
+
// resident's carrier never drifts mid-session. The env-override path instead
|
|
33
|
+
// re-reads on EVERY call: editing that file mid-session INTENTIONALLY drifts the
|
|
34
|
+
// rendered carrier → bridgeConfigSignature changes → the live session is judged
|
|
35
|
+
// incompatible and the next turn opens a fresh ACP session with the new carrier.
|
|
36
|
+
// That per-turn rebuild is the accepted cost of the A/B opt-in surface, never the
|
|
37
|
+
// shipped default (which stays cached precisely so a resident never rebuilds).
|
|
38
|
+
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
40
|
+
import { dirname, join, resolve } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
|
|
45
|
+
|
|
46
|
+
export interface EngravingParams {
|
|
47
|
+
/** Claude-only this cut; kept as a field so the `{{backend}}` token interpolates. */
|
|
48
|
+
backend: string;
|
|
49
|
+
/** MCP server names exposed to the session. SORTED before render for determinism. */
|
|
50
|
+
mcpServerNames: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type CachedSource = { filePath: string; content: string };
|
|
54
|
+
let cached: CachedSource | null = null;
|
|
55
|
+
|
|
56
|
+
/** Point the loader at an alternate engraving file (A/B); bypasses the cache. */
|
|
57
|
+
function resolveEngravingPath(): string {
|
|
58
|
+
const envPath = process.env.ENTWURF_ACP_ENGRAVING_PATH?.trim();
|
|
59
|
+
return envPath ? resolve(envPath) : DEFAULT_ENGRAVING_PATH;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadSource(filePath: string): string {
|
|
63
|
+
// Env-override path → always re-read (A/B experimentation). Default path →
|
|
64
|
+
// cache once so a mid-session operator edit cannot drift the carrier (and thus
|
|
65
|
+
// bridgeConfigSignature) between turns of a resident.
|
|
66
|
+
if (filePath !== DEFAULT_ENGRAVING_PATH) {
|
|
67
|
+
return readFileSync(filePath, "utf8");
|
|
68
|
+
}
|
|
69
|
+
if (!cached || cached.filePath !== filePath) {
|
|
70
|
+
cached = { filePath, content: readFileSync(filePath, "utf8") };
|
|
71
|
+
}
|
|
72
|
+
return cached.content;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function interpolate(template: string, params: EngravingParams): string {
|
|
76
|
+
// Sort so a caller-side ordering difference can never drift the rendered text
|
|
77
|
+
// (and therefore the config signature) — GPT c32a6c8 determinism guard.
|
|
78
|
+
const names = [...params.mcpServerNames].sort();
|
|
79
|
+
const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
|
|
80
|
+
return template.replace(/\{\{backend\}\}/g, params.backend).replace(/\{\{mcp_servers\}\}/g, mcpList);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The rendered engraving carrier, or null when an ENV-OVERRIDE engraving file
|
|
85
|
+
* (`ENTWURF_ACP_ENGRAVING_PATH`) is empty, whitespace-only, missing, or
|
|
86
|
+
* unreadable — that null is the operator opt-out. The SHIPPED default, by
|
|
87
|
+
* contrast, IS the auto-memory containment lever (its non-empty carrier replaces
|
|
88
|
+
* the claude_code preset, stripping the auto-memory advertisement) and MUST be
|
|
89
|
+
* present + non-empty: if the shipped default is missing/unpackaged/empty this
|
|
90
|
+
* THROWS (fail-loud, Detour C) rather than silently shipping with the carrier
|
|
91
|
+
* strip off. To opt the carrier out, point the env override at an empty file.
|
|
92
|
+
* Callers MUST treat null as "no carrier configured" and omit `_meta.systemPrompt`
|
|
93
|
+
* entirely (passing "" as the `appendSystemPrompt` signature input) so
|
|
94
|
+
* subscription billing is never reclassified.
|
|
95
|
+
*/
|
|
96
|
+
export function loadEngraving(params: EngravingParams): string | null {
|
|
97
|
+
const filePath = resolveEngravingPath();
|
|
98
|
+
const isShippedDefault = filePath === DEFAULT_ENGRAVING_PATH;
|
|
99
|
+
let source: string;
|
|
100
|
+
try {
|
|
101
|
+
source = loadSource(filePath);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (isShippedDefault) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`entwurf: shipped engraving carrier unreadable at ${filePath} — it is the auto-memory ` +
|
|
106
|
+
`containment lever; refusing to proceed with containment silently degraded. (${(err as Error).message})`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const rendered = interpolate(source, params).trim();
|
|
112
|
+
if (rendered.length === 0) {
|
|
113
|
+
if (isShippedDefault) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`entwurf: shipped engraving carrier at ${filePath} is empty — it is the auto-memory ` +
|
|
116
|
+
`containment lever; refusing to proceed with the carrier strip silently off. ` +
|
|
117
|
+
`(opt out via an empty ENTWURF_ACP_ENGRAVING_PATH file instead)`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return rendered;
|
|
123
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// ACP plugin — ACP session_notification → pi event stream mapper (S2c).
|
|
2
|
+
//
|
|
3
|
+
// Translates the ACP backend's streaming notifications into pi's
|
|
4
|
+
// AssistantMessageEvent protocol (text/thinking blocks + tool/permission
|
|
5
|
+
// notices + usage), maintaining a running `partial: AssistantMessage`.
|
|
6
|
+
//
|
|
7
|
+
// Claude-only collapse (NEXT §스코프 + oracle F): the 0.11.0 event-mapper
|
|
8
|
+
// reconciled three backend dialects (Claude rawOutput=array / Codex
|
|
9
|
+
// CallToolResult / Gemini content[]) plus an entwurf_v2 sent-box custom promotion.
|
|
10
|
+
// This lane drives ONLY claude-agent-acp, so the dialect collapses to one
|
|
11
|
+
// (rawOutput=array) and the entwurf/gemini/codex special-casing is dropped.
|
|
12
|
+
//
|
|
13
|
+
// CRITICAL boundary (GPT S2c Q3): an ACP `tool_call` / `tool_call_update` is
|
|
14
|
+
// rendered as an INFORMATIONAL TEXT NOTICE, never a structured pi `toolcall_*`
|
|
15
|
+
// event. The ACP child already executes its own tools (Claude Code side); a
|
|
16
|
+
// structured pi ToolCall would signal pi's agent loop to RE-EXECUTE it. Tools
|
|
17
|
+
// surface honestly in the transcript as `[tool:*]` notices instead. Thinking
|
|
18
|
+
// (`agent_thought_chunk`) IS structured — pi never executes thinking.
|
|
19
|
+
|
|
20
|
+
import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
21
|
+
|
|
22
|
+
const NOTICE_TITLE_MAX = 80;
|
|
23
|
+
const NOTICE_SUMMARY_MAX = 160;
|
|
24
|
+
|
|
25
|
+
/** Identity fields for the running assistant message. */
|
|
26
|
+
export interface AcpStreamIdentity {
|
|
27
|
+
api: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
model: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ObservedToolState = {
|
|
33
|
+
title: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
notifiedRunning?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type AcpPiStreamState = {
|
|
39
|
+
stream: AssistantMessageEventStream;
|
|
40
|
+
output: AssistantMessage;
|
|
41
|
+
openTextIndex?: number;
|
|
42
|
+
openThinkingIndex?: number;
|
|
43
|
+
/** When false, tool/permission notices are suppressed (kept terse for smokes). */
|
|
44
|
+
showToolNotifications?: boolean;
|
|
45
|
+
observedTools?: Map<string, ObservedToolState>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A zeroed pi Usage block. */
|
|
49
|
+
function zeroUsage(): AssistantMessage["usage"] {
|
|
50
|
+
return {
|
|
51
|
+
input: 0,
|
|
52
|
+
output: 0,
|
|
53
|
+
cacheRead: 0,
|
|
54
|
+
cacheWrite: 0,
|
|
55
|
+
totalTokens: 0,
|
|
56
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a fresh stream state with an empty running AssistantMessage. The caller
|
|
62
|
+
* (streamSimple) pushes the `start` event, drives notifications through
|
|
63
|
+
* applyAcpSessionUpdate, then finalize + done.
|
|
64
|
+
*/
|
|
65
|
+
export function createAcpStreamState(
|
|
66
|
+
stream: AssistantMessageEventStream,
|
|
67
|
+
identity: AcpStreamIdentity,
|
|
68
|
+
opts?: { showToolNotifications?: boolean; timestamp?: number },
|
|
69
|
+
): AcpPiStreamState {
|
|
70
|
+
const output: AssistantMessage = {
|
|
71
|
+
role: "assistant",
|
|
72
|
+
content: [],
|
|
73
|
+
api: identity.api,
|
|
74
|
+
provider: identity.provider,
|
|
75
|
+
model: identity.model,
|
|
76
|
+
usage: zeroUsage(),
|
|
77
|
+
stopReason: "stop",
|
|
78
|
+
timestamp: opts?.timestamp ?? Date.now(),
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
stream,
|
|
82
|
+
output,
|
|
83
|
+
showToolNotifications: opts?.showToolNotifications ?? true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getObservedTools(state: AcpPiStreamState): Map<string, ObservedToolState> {
|
|
88
|
+
if (!state.observedTools) state.observedTools = new Map();
|
|
89
|
+
return state.observedTools;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function closeThinkingBlock(state: AcpPiStreamState): void {
|
|
93
|
+
if (state.openThinkingIndex == null) return;
|
|
94
|
+
const index = state.openThinkingIndex;
|
|
95
|
+
const block = state.output.content[index] as { thinking?: string };
|
|
96
|
+
state.stream.push({
|
|
97
|
+
type: "thinking_end",
|
|
98
|
+
contentIndex: index,
|
|
99
|
+
content: block?.thinking ?? "",
|
|
100
|
+
partial: state.output,
|
|
101
|
+
});
|
|
102
|
+
state.openThinkingIndex = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function closeTextBlock(state: AcpPiStreamState): void {
|
|
106
|
+
if (state.openTextIndex == null) return;
|
|
107
|
+
const index = state.openTextIndex;
|
|
108
|
+
const block = state.output.content[index] as { text?: string };
|
|
109
|
+
state.stream.push({ type: "text_end", contentIndex: index, content: block?.text ?? "", partial: state.output });
|
|
110
|
+
state.openTextIndex = undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ensureTextBlock(state: AcpPiStreamState): number {
|
|
114
|
+
if (state.openTextIndex != null) return state.openTextIndex;
|
|
115
|
+
closeThinkingBlock(state);
|
|
116
|
+
const index = state.output.content.length;
|
|
117
|
+
state.output.content.push({ type: "text", text: "" });
|
|
118
|
+
state.openTextIndex = index;
|
|
119
|
+
state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
|
|
120
|
+
return index;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ensureThinkingBlock(state: AcpPiStreamState): number {
|
|
124
|
+
if (state.openThinkingIndex != null) return state.openThinkingIndex;
|
|
125
|
+
closeTextBlock(state);
|
|
126
|
+
const index = state.output.content.length;
|
|
127
|
+
state.output.content.push({ type: "thinking", thinking: "", thinkingSignature: "" });
|
|
128
|
+
state.openThinkingIndex = index;
|
|
129
|
+
state.stream.push({ type: "thinking_start", contentIndex: index, partial: state.output });
|
|
130
|
+
return index;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Emit a standalone one-line notice as its own text block. Used for tool /
|
|
135
|
+
* permission events — informational, NOT structured tool calls.
|
|
136
|
+
*/
|
|
137
|
+
function pushNotice(state: AcpPiStreamState, text: string): void {
|
|
138
|
+
if (!state.showToolNotifications || !text.trim()) return;
|
|
139
|
+
closeThinkingBlock(state);
|
|
140
|
+
closeTextBlock(state);
|
|
141
|
+
const index = state.output.content.length;
|
|
142
|
+
state.output.content.push({ type: "text", text });
|
|
143
|
+
state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
|
|
144
|
+
state.stream.push({ type: "text_delta", contentIndex: index, delta: text, partial: state.output });
|
|
145
|
+
state.stream.push({ type: "text_end", contentIndex: index, content: text, partial: state.output });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Sanitize an inline fragment for the one-line `[tool:*]` / `[permission:*]`
|
|
150
|
+
* notice surface: collapse whitespace, neutralize backtick fences (which would
|
|
151
|
+
* otherwise swallow following lines in chat renderers), truncate with ellipsis.
|
|
152
|
+
*/
|
|
153
|
+
export function sanitizeNoticeFragment(text: string | null | undefined, max: number): string {
|
|
154
|
+
if (!text) return "";
|
|
155
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
156
|
+
const fenceSafe = collapsed.replace(/`{3,}/g, "[fence]").replace(/`/g, "'");
|
|
157
|
+
if (fenceSafe.length <= max) return fenceSafe;
|
|
158
|
+
return `${fenceSafe.slice(0, max - 1)}…`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function firstTextItem(arr: unknown[]): string | undefined {
|
|
162
|
+
for (const item of arr) {
|
|
163
|
+
if (item && typeof item === "object" && (item as { type?: string }).type === "text") {
|
|
164
|
+
const text = String((item as { text?: unknown }).text ?? "").trim();
|
|
165
|
+
if (text) return text;
|
|
166
|
+
}
|
|
167
|
+
// ACP-normalized shape: { type:"content", content:{ type:"text", text } }
|
|
168
|
+
if (item && typeof item === "object" && (item as { type?: string }).type === "content") {
|
|
169
|
+
const inner = (item as { content?: { type?: string; text?: unknown } }).content;
|
|
170
|
+
if (inner && typeof inner === "object" && inner.type === "text") {
|
|
171
|
+
const text = String(inner.text ?? "").trim();
|
|
172
|
+
if (text) return text;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Claude ACP rawOutput is an array of text items; tolerate a CallToolResult body too. */
|
|
180
|
+
function firstTextContent(value: unknown): string | undefined {
|
|
181
|
+
if (Array.isArray(value)) {
|
|
182
|
+
const text = firstTextItem(value);
|
|
183
|
+
if (text) return text;
|
|
184
|
+
}
|
|
185
|
+
if (!value || typeof value !== "object") return undefined;
|
|
186
|
+
const inner = (value as { content?: unknown }).content;
|
|
187
|
+
if (Array.isArray(inner)) {
|
|
188
|
+
const text = firstTextItem(inner);
|
|
189
|
+
if (text) return text;
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** MCP-level error flag on a CallToolResult-shaped rawOutput. */
|
|
195
|
+
function rawOutputHasError(rawOutput: unknown): boolean {
|
|
196
|
+
if (!rawOutput || typeof rawOutput !== "object") return false;
|
|
197
|
+
return (rawOutput as { isError?: unknown }).isError === true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function titleForTool(update: Record<string, unknown>, previousTitle?: string): string {
|
|
201
|
+
const meta = update?._meta as { claudeCode?: { toolName?: string } } | undefined;
|
|
202
|
+
return String(update?.title ?? previousTitle ?? meta?.claudeCode?.toolName ?? update?.toolCallId ?? "Tool");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Render an ACP tool_call / tool_call_update as a text notice (NEVER a
|
|
207
|
+
* structured toolcall — the ACP child already executed it).
|
|
208
|
+
*/
|
|
209
|
+
function renderToolUpdate(state: AcpPiStreamState, update: Record<string, unknown>): void {
|
|
210
|
+
const toolCallId = String(update?.toolCallId ?? "");
|
|
211
|
+
if (!toolCallId) return;
|
|
212
|
+
const observedTools = getObservedTools(state);
|
|
213
|
+
const previous = observedTools.get(toolCallId);
|
|
214
|
+
const title = titleForTool(update, previous?.title);
|
|
215
|
+
const status = typeof update?.status === "string" ? (update.status as string) : previous?.status;
|
|
216
|
+
const updateContent = Array.isArray(update?.content) ? (update.content as unknown[]) : undefined;
|
|
217
|
+
const meta = update?._meta as { terminal_output?: unknown } | undefined;
|
|
218
|
+
|
|
219
|
+
let notifiedRunning = previous?.notifiedRunning;
|
|
220
|
+
|
|
221
|
+
if (update.sessionUpdate === "tool_call") {
|
|
222
|
+
observedTools.set(toolCallId, { title, status, notifiedRunning });
|
|
223
|
+
pushNotice(state, `\n[tool:start] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// tool_call_update
|
|
228
|
+
if (meta?.terminal_output && !notifiedRunning) {
|
|
229
|
+
notifiedRunning = true;
|
|
230
|
+
pushNotice(state, `\n[tool:running] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (status && status !== previous?.status) {
|
|
234
|
+
const summary = firstTextContent(update?.rawOutput) ?? firstTextContent(updateContent);
|
|
235
|
+
const suffix = summary ? ` — ${sanitizeNoticeFragment(summary, NOTICE_SUMMARY_MAX)}` : "";
|
|
236
|
+
if (status === "completed") {
|
|
237
|
+
const label = rawOutputHasError(update?.rawOutput) ? "tool:failed" : "tool:done";
|
|
238
|
+
pushNotice(state, `\n[${label}] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
|
|
239
|
+
} else if (status === "failed") {
|
|
240
|
+
pushNotice(state, `\n[tool:failed] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
|
|
241
|
+
} else if (status === "cancelled") {
|
|
242
|
+
pushNotice(state, `\n[tool:cancelled] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
observedTools.set(toolCallId, { title, status, notifiedRunning });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Push a permission-decision notice (informational text, not a tool call). */
|
|
250
|
+
export function pushPermissionNotice(state: AcpPiStreamState, title: string, decision: string): void {
|
|
251
|
+
pushNotice(state, `\n[permission:${decision}] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The textSignature marker stamped on lifecycle progress notices (S2f). It is
|
|
256
|
+
* what lets the transcript flatten (context.ts) and the reuse-compat signature
|
|
257
|
+
* (session-store.ts) EXCLUDE these blocks: a lifecycle notice is display-only —
|
|
258
|
+
* it must never replay into an ACP prompt nor perturb a reuse signature, whether
|
|
259
|
+
* present or absent. Without the marker the "output-side only" claim is L0 hope.
|
|
260
|
+
*/
|
|
261
|
+
export const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Push a one-line ACP turn-lifecycle progress notice (`[acp: …]`) as its own
|
|
265
|
+
* text block, stamped with LIFECYCLE_NOTICE_SIGNATURE. Two ways it differs from
|
|
266
|
+
* tool/permission notices:
|
|
267
|
+
* 1. It IGNORES `showToolNotifications`. Turn progress is ALWAYS visible — a
|
|
268
|
+
* silent bootstrap (overlay → spawn → init → newSession → setModel → first
|
|
269
|
+
* token) reads as a hang. Only the verbose tool stream is suppressible.
|
|
270
|
+
* 2. The marker keeps it display-only — out of the transcript replay and the
|
|
271
|
+
* reuse-compat signature (the two consumers filter on the signature).
|
|
272
|
+
*/
|
|
273
|
+
export function pushAcpLifecycleNotice(state: AcpPiStreamState, text: string): void {
|
|
274
|
+
const line = `\n[acp: ${sanitizeNoticeFragment(text, NOTICE_TITLE_MAX)}]\n`;
|
|
275
|
+
closeThinkingBlock(state);
|
|
276
|
+
closeTextBlock(state);
|
|
277
|
+
const index = state.output.content.length;
|
|
278
|
+
state.output.content.push({ type: "text", text: line, textSignature: LIFECYCLE_NOTICE_SIGNATURE });
|
|
279
|
+
state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
|
|
280
|
+
state.stream.push({ type: "text_delta", contentIndex: index, delta: line, partial: state.output });
|
|
281
|
+
state.stream.push({ type: "text_end", contentIndex: index, content: line, partial: state.output });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Apply one ACP `session_notification` update to the stream state. Unknown
|
|
286
|
+
* update kinds are ignored (forward-compatible).
|
|
287
|
+
*/
|
|
288
|
+
export function applyAcpSessionUpdate(
|
|
289
|
+
state: AcpPiStreamState,
|
|
290
|
+
update: Record<string, unknown> | null | undefined,
|
|
291
|
+
): void {
|
|
292
|
+
if (!update || typeof update !== "object") return;
|
|
293
|
+
|
|
294
|
+
switch (update.sessionUpdate) {
|
|
295
|
+
case "agent_message_chunk": {
|
|
296
|
+
const content = update.content as { type?: string; text?: unknown } | undefined;
|
|
297
|
+
if (content?.type !== "text") return;
|
|
298
|
+
const delta = String(content.text ?? "");
|
|
299
|
+
if (!delta) return;
|
|
300
|
+
const index = ensureTextBlock(state);
|
|
301
|
+
(state.output.content[index] as { text: string }).text += delta;
|
|
302
|
+
state.stream.push({ type: "text_delta", contentIndex: index, delta, partial: state.output });
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "agent_thought_chunk": {
|
|
306
|
+
const content = update.content as { type?: string; text?: unknown } | undefined;
|
|
307
|
+
if (content?.type !== "text") return;
|
|
308
|
+
const delta = String(content.text ?? "");
|
|
309
|
+
if (!delta) return;
|
|
310
|
+
const index = ensureThinkingBlock(state);
|
|
311
|
+
(state.output.content[index] as { thinking: string }).thinking += delta;
|
|
312
|
+
state.stream.push({ type: "thinking_delta", contentIndex: index, delta, partial: state.output });
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "tool_call":
|
|
316
|
+
case "tool_call_update": {
|
|
317
|
+
renderToolUpdate(state, update);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case "usage_update": {
|
|
321
|
+
// S2c maps COARSE ACP usage only: `used` is occupancy-shaped and does
|
|
322
|
+
// not split cleanly into pi's input/output/cache fields, so we fill
|
|
323
|
+
// totalTokens + cost.total and leave the rest zero. Richer accounting
|
|
324
|
+
// is a later lane (S2e/PR-polish).
|
|
325
|
+
if (typeof update.used === "number") state.output.usage.totalTokens = update.used;
|
|
326
|
+
const cost = update.cost as { amount?: unknown } | undefined;
|
|
327
|
+
if (typeof cost?.amount === "number") state.output.usage.cost.total = cost.amount;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
default:
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Close any open text/thinking block. Call before pushing the terminal done. */
|
|
336
|
+
export function finalizeAcpStreamState(state: AcpPiStreamState): void {
|
|
337
|
+
closeThinkingBlock(state);
|
|
338
|
+
closeTextBlock(state);
|
|
339
|
+
}
|