@junghanacs/entwurf 0.34.2 → 0.35.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/CHANGELOG.md +244 -0
- package/README.md +7 -0
- package/VERIFY.md +16 -5
- package/demo/README.md +109 -175
- package/demo/demo-baseline.sh +5 -1
- package/demo/demo.sh +6 -2
- package/demo/herdr-pi-cc-20261011.provenance.json +64 -0
- package/docs/acp-backend-rail.md +28 -2
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/setup-clean-host.md +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +119 -11
- package/package.json +4 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
- package/pi-extensions/lib/acp/model-ids.js +77 -0
- package/pi-extensions/lib/acp/models.ts +15 -22
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/mux-fresh-call.ts +3 -0
- package/pi-extensions/lib/resume-launch-identity.ts +12 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +153 -13
- package/run.sh +118 -20
- package/scripts/check-acp-backend-preflight.ts +10 -2
- package/scripts/check-acp-cortex.ts +7 -6
- package/scripts/check-acp-prompt-lifecycle.ts +2 -2
- package/scripts/check-acp-provider-surface.ts +1 -1
- package/scripts/check-acp-session-reuse.ts +5 -5
- package/scripts/check-acp-stop-reason.ts +2 -2
- package/scripts/check-acp-stream-hooks.ts +2 -2
- package/scripts/check-acp-usage-accounting.ts +2 -2
- package/scripts/check-fresh-call-dispatch.ts +47 -4
- package/scripts/check-gate-qualification.ts +8 -5
- package/scripts/check-probe-ordering.ts +13 -6
- package/scripts/check-release-gate-outcomes.ts +159 -1
- package/scripts/check-resume-launch-identity.ts +38 -0
- package/scripts/ci-qualify-decide.sh +114 -17
- package/scripts/mutants/acp-cortex.json +17 -8
- package/scripts/mutants/acp-set-model-readback.json +28 -0
- package/scripts/mutants/demo-reproducibility.json +155 -0
- package/scripts/mutants/fresh-call-dispatch.json +11 -0
- package/scripts/mutants/herdr-dev-checkout.json +70 -0
- package/scripts/mutants/pi-durable.json +117 -0
- package/scripts/mutants/release-gate.json +28 -2
- package/scripts/mutants/resume-launch-identity.json +15 -0
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/resolve-acp-bridge.ts +1 -1
- package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
- package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
- package/scripts/smoke-acp-long-turn-live.ts +1 -1
- package/scripts/smoke-acp-mcp-live.ts +1 -1
- package/scripts/smoke-acp-memory-containment-live.ts +1 -1
- package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
- package/scripts/smoke-acp-overlay-live.ts +1 -1
- package/scripts/smoke-acp-provider-live.ts +1 -1
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +1 -1
- package/scripts/smoke-acp-skill-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -32,8 +32,11 @@
|
|
|
32
32
|
// re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
|
|
33
33
|
// the loader then exposes.
|
|
34
34
|
import { getModels } from "@earendil-works/pi-ai/compat";
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-ids.js";
|
|
36
|
+
// The provider id and the curated ID lists live in the dependency-free
|
|
37
|
+
// `model-ids.js` leaf, shared with the launch seams (#128); this file is the only
|
|
38
|
+
// one that loads the upstream catalog to turn them into registry rows.
|
|
39
|
+
export { CORTEX_MODEL_PREFIX, CURATED_ANCHOR_MODEL_ID, PROVIDER_ID, SUPPORTED_ANTHROPIC_MODEL_IDS, SUPPORTED_CORTEX_MODEL_IDS, } from "./model-ids.js";
|
|
37
40
|
// #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
|
|
38
41
|
// provider defines custom models, but the ACP plugin consumes NO key: backend
|
|
39
42
|
// auth belongs to the operator's own Claude CLI child process (AGENTS
|
|
@@ -45,15 +48,8 @@ export const PROVIDER_ID = "entwurf";
|
|
|
45
48
|
// — that silences the warning but keeps the wrong auth-boundary shape. The
|
|
46
49
|
// check-auth-boundary gate pins this.
|
|
47
50
|
export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// Exported so the claude backend adapter (backend-adapter.ts) can answer
|
|
51
|
-
// `routeModel` without re-deriving the set from curatedClaudeModels().
|
|
52
|
-
export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1"];
|
|
53
|
-
/** The anchor model whose absence is a hard registry regression, not a soft skip. */
|
|
54
|
-
export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
|
|
55
|
-
// Anthropic's registry reports 1M for Sonnet 5, Opus 5 and Fable 5.1, and the
|
|
56
|
-
// entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window is the
|
|
51
|
+
// Anthropic's registry reports 1M for Sonnet 5.5, Opus 5.5 and Fable 5.1, and the
|
|
52
|
+
// entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window was the
|
|
57
53
|
// whole point of the 0.12.3 bump — it is the compact-free long-context floor the
|
|
58
54
|
// earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
|
|
59
55
|
// future registry value can't silently inflate the surface past what we verify.
|
|
@@ -82,7 +78,7 @@ function claudeContextWindow(model) {
|
|
|
82
78
|
// carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
|
|
83
79
|
// authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
|
|
84
80
|
// native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
|
|
85
|
-
// `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
81
|
+
// `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
86
82
|
// recover the native id, which is enforced per-turn via
|
|
87
83
|
// session/set_config_option — never a launch `-m` pin (CP0-M).
|
|
88
84
|
//
|
|
@@ -91,14 +87,7 @@ function claudeContextWindow(model) {
|
|
|
91
87
|
// the running cortex no longer serves fails LOUD at set-model, before the
|
|
92
88
|
// prompt (measured: `Unsupported model: …` names the live set). Adding an id
|
|
93
89
|
// here is the same verify-both-axes commitment as the Claude set — do not
|
|
94
|
-
// extend casually.
|
|
95
|
-
export const CORTEX_MODEL_PREFIX = "cortex-";
|
|
96
|
-
export const SUPPORTED_CORTEX_MODEL_IDS = [
|
|
97
|
-
"cortex-auto",
|
|
98
|
-
"cortex-claude-opus-5",
|
|
99
|
-
"cortex-claude-sonnet-5",
|
|
100
|
-
"cortex-openai-gpt-5.4",
|
|
101
|
-
];
|
|
90
|
+
// extend casually. The prefix and the four ids are declared in model-ids.js.
|
|
102
91
|
// Hand-set conservative context window. Cortex reports the LIVE window via its
|
|
103
92
|
// own ACP session config, not this curated metadata — this is a floor for the
|
|
104
93
|
// registry surface, deliberately not inflated to the Claude 1M.
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
* a resume runs no turn (measured — the transcript stayed byte-identical), so there is no turn
|
|
47
47
|
* outcome for this module to claim.
|
|
48
48
|
*/
|
|
49
|
+
import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
|
|
49
50
|
import { buildResumePiArgs } from "./entwurf-resume-args.js";
|
|
50
51
|
import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
|
|
51
52
|
import { ResumeBackendUnsupportedError, resolveResumeLaunchIdentity, } from "./resume-launch-identity.js";
|
|
@@ -129,6 +130,15 @@ export async function visibleResume(targetGardenId, deps) {
|
|
|
129
130
|
identity = deps.resolveIdentity(targetGardenId);
|
|
130
131
|
}
|
|
131
132
|
catch (err) {
|
|
133
|
+
// #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
|
|
134
|
+
// by substring. Same shape as `target-not-pi`: a named refusal matched on its FIELD.
|
|
135
|
+
if (err instanceof AcpModelNotCuratedError) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: err.reason,
|
|
139
|
+
detail: `${targetGardenId} would launch model ${JSON.stringify(err.model)} under the entwurf provider (the resolved launch pair, which may differ from the recorded provider)`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
132
142
|
if (err instanceof ResumeBackendUnsupportedError) {
|
|
133
143
|
return {
|
|
134
144
|
ok: false,
|
|
@@ -210,6 +220,7 @@ export async function observeSameGidSocket(targetGardenId, socketPath, deps, tim
|
|
|
210
220
|
const REJECT_HINT = {
|
|
211
221
|
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
212
222
|
"target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
|
|
223
|
+
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
213
224
|
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
214
225
|
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
215
226
|
"target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* the one address path, on both rails.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
27
|
+
import { ACP_MODEL_NOT_CURATED, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
27
28
|
import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.js";
|
|
28
|
-
import { mintNonce } from "./fresh-call-composition.js";
|
|
29
|
+
import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.js";
|
|
29
30
|
import { createHerdrRunner, herdrFreshCall, renderHerdrFreshCall, resolveHerdrContext, } from "./herdr-fresh-call.js";
|
|
30
31
|
import { freshCall, renderFreshCall } from "./mux-fresh-call.js";
|
|
31
32
|
/** herdr's own marker, read for its EXACT value. `[측정 2026-09-14]` herdr exports `HERDR_ENV=1`
|
|
@@ -33,6 +34,25 @@ import { freshCall, renderFreshCall } from "./mux-fresh-call.js";
|
|
|
33
34
|
export function selectFreshCallRail(env) {
|
|
34
35
|
return env.HERDR_ENV === "1" ? "herdr" : "tmux";
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
|
|
39
|
+
* resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
|
|
40
|
+
* would open on `cortex-claude-sonnet-5` — another vendor and billing rail — with a receipt that
|
|
41
|
+
* still names the requested id. Asked here, at the one root both rails share, so neither rail can
|
|
42
|
+
* forget it. It reads the model the way the rails will (trimmed), and only a model that already
|
|
43
|
+
* passes their grammar: a malformed one keeps its own `model-invalid` answer from the rail.
|
|
44
|
+
*/
|
|
45
|
+
export function isUncuratedEntwurfPiModel(backend, model) {
|
|
46
|
+
if (backend !== "pi")
|
|
47
|
+
return false;
|
|
48
|
+
const trimmed = model.trim();
|
|
49
|
+
if (!isSafeFreshCallModel(trimmed))
|
|
50
|
+
return false;
|
|
51
|
+
const slash = trimmed.indexOf("/");
|
|
52
|
+
if (slash < 0 || trimmed.slice(0, slash) !== PROVIDER_ID)
|
|
53
|
+
return false;
|
|
54
|
+
return !isCuratedAcpModelId(trimmed.slice(slash + 1));
|
|
55
|
+
}
|
|
36
56
|
/**
|
|
37
57
|
* Open a sibling on whichever rail this process is actually standing in.
|
|
38
58
|
*
|
|
@@ -49,6 +69,12 @@ export function selectFreshCallRail(env) {
|
|
|
49
69
|
* them rather than inheriting a rule from a topology it does not share.
|
|
50
70
|
*/
|
|
51
71
|
export async function dispatchFreshCall(request, env = process.env, spawn, nonce = mintNonce()) {
|
|
72
|
+
// Before either rail is asked anything: both would open a pane on the wrong row.
|
|
73
|
+
if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
|
|
74
|
+
return selectFreshCallRail(env) === "herdr"
|
|
75
|
+
? { rail: "herdr", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } }
|
|
76
|
+
: { rail: "tmux", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } };
|
|
77
|
+
}
|
|
52
78
|
if (selectFreshCallRail(env) === "herdr") {
|
|
53
79
|
const context = resolveHerdrContext(env);
|
|
54
80
|
if (!context.ok) {
|
|
@@ -789,6 +789,9 @@ const HERDR_REJECT_HINT = {
|
|
|
789
789
|
"caller-identity-unavailable": "this surface has no record-backed identity, so a sibling would have nowhere to call home",
|
|
790
790
|
"model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
|
|
791
791
|
"model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
|
|
792
|
+
// #128: decided at the dispatch root (`fresh-call-dispatch.ts`), worded here because this rail's
|
|
793
|
+
// import fence admits no ACP module. The curated list itself lives in `acp/model-ids.js`.
|
|
794
|
+
"acp-model-not-curated": "an entwurf model must be one exact curated ACP id — Pi would otherwise pick another row by substring (a retired claude-sonnet-5 lands on cortex-claude-sonnet-5), so nothing was opened and nothing was aliased",
|
|
792
795
|
"task-empty": "a sibling opened with nothing to do is a window nobody asked for",
|
|
793
796
|
"task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
|
|
794
797
|
"herdr-context-missing": "HERDR_ENV/HERDR_BIN_PATH are absent, so this process is not inside herdr and this rail does not exist here",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
* the session the window is actually in. It still reports no `pane_current_path`, and
|
|
86
86
|
* there is no "session created" field because nothing here creates one.
|
|
87
87
|
*/
|
|
88
|
+
import { ACP_MODEL_NOT_CURATED_HINT } from "./acp/model-ids.js";
|
|
88
89
|
import { callbackEnvAssignments } from "./callback-env.js";
|
|
89
90
|
import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
|
|
90
91
|
import { CODEX_CALLER_SEAT_HINT, resolveCodexCallerSeat, } from "./codex-caller-seat.js";
|
|
@@ -490,6 +491,7 @@ const REJECT_HINT = {
|
|
|
490
491
|
"tmux-session-missing": "no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
|
|
491
492
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
492
493
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
494
|
+
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
493
495
|
"task-empty": "task is empty after trimming",
|
|
494
496
|
"task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
|
|
495
497
|
"runtime-unresolved": "the backend's runtime is not installed on PATH",
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync } from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
|
+
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
22
23
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
|
|
23
24
|
import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
* same-id resume at all.
|
|
26
|
+
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
27
|
+
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
27
28
|
*
|
|
28
29
|
* Everything else this function throws is a defect in the record, the transcript or the
|
|
29
30
|
* environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
|
|
@@ -126,11 +127,19 @@ export function resolveResumeLaunchIdentity(gardenId) {
|
|
|
126
127
|
if (!identity.cwd) {
|
|
127
128
|
throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
|
|
128
129
|
}
|
|
130
|
+
// #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
|
|
131
|
+
// `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
|
|
132
|
+
// provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
|
|
133
|
+
// `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
|
|
134
|
+
const provider = explicitExtensions.provider ?? identity.provider;
|
|
135
|
+
const model = explicitExtensions.modelOverride ?? resumeModel;
|
|
136
|
+
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
|
|
137
|
+
throw new AcpModelNotCuratedError(model);
|
|
129
138
|
return {
|
|
130
139
|
sessionFile,
|
|
131
140
|
cwd: identity.cwd,
|
|
132
141
|
explicitExtensionArgs: explicitExtensions.args,
|
|
133
|
-
provider
|
|
134
|
-
model
|
|
142
|
+
provider,
|
|
143
|
+
model,
|
|
135
144
|
};
|
|
136
145
|
}
|
|
@@ -58,6 +58,24 @@
|
|
|
58
58
|
* vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
|
|
59
59
|
* closed, our own marker removed, the reason kept by name — and nothing retries it.
|
|
60
60
|
*
|
|
61
|
+
* WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
|
|
62
|
+
* unread body. So after a read the notices still queued for that mail would each cost a turn and an
|
|
63
|
+
* empty read. The contact keeps the native `Submission` handle of each notice it admitted, with the
|
|
64
|
+
* `.msg` names that notice announced, only while that mail is unread. After every
|
|
65
|
+
* `entwurf_inbox_read` attempt — whatever its garden id or outcome — it lists its OWN mailbox once
|
|
66
|
+
* and withdraws, through the native `Submission.abort` and the read's own invocation Context, each
|
|
67
|
+
* notice none of whose names is still `.msg.delivered`. That per-notice file predicate is the only
|
|
68
|
+
* authority: no garden-id guard, no requestId lookup, no queue of our own, no rewritten text. A placed
|
|
69
|
+
* or settled notice answers so and is let go; a withdraw that fails is let go too, named in one
|
|
70
|
+
* stderr line and on the receiver report, never retried and never retiring the doorbell; a mailbox
|
|
71
|
+
* that cannot be listed proves nothing absent, so every notice is kept and that is named the same
|
|
72
|
+
* way. The read's own result or error is returned unchanged — no diagnostic joins it. Ring passes
|
|
73
|
+
* and reads share one gate: a read waits for an admission in flight (else its drain could beat the
|
|
74
|
+
* handle it must withdraw), and an arrival during a read is rung once the read lets go. The count
|
|
75
|
+
* in a notice is its admission-time snapshot. Not covered: notices admitted before a restart (no
|
|
76
|
+
* handle), mail drained by another process (until this contact's next read), and operator abort,
|
|
77
|
+
* which natively withdraws queued notices while their mail stays unread.
|
|
78
|
+
*
|
|
61
79
|
* VISIBLE IDENTITY (a static projection). `withGardenIdentity` hands the native TUI a view whose
|
|
62
80
|
* every conversation label carries this citizen's garden id, so the footer the TUI keeps on screen
|
|
63
81
|
* names the citizen; the bootstrap composes it only after birth succeeded. That is evidence of what
|
|
@@ -79,7 +97,8 @@
|
|
|
79
97
|
*
|
|
80
98
|
* NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
|
|
81
99
|
* result commits, so a crash between the two loses that body to the model; recovery settles the
|
|
82
|
-
* call as interrupted and no doorbell rings again.
|
|
100
|
+
* call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
|
|
101
|
+
* bodies before it (the bridge returns its error), and those too never reach the model. No resume.
|
|
83
102
|
*/
|
|
84
103
|
import * as fs from "node:fs";
|
|
85
104
|
import * as path from "node:path";
|
|
@@ -461,7 +480,8 @@ export function createPiDurableContact(options) {
|
|
|
461
480
|
throw new Error(`${name} arguments must be an object`);
|
|
462
481
|
}
|
|
463
482
|
const client = await waitReady(context?.abortSignal);
|
|
464
|
-
const
|
|
483
|
+
const call = () => client.callTool(name, (args ?? {}), context?.abortSignal);
|
|
484
|
+
const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
|
|
465
485
|
if (result.isError)
|
|
466
486
|
throw new Error(result.text || `${name} failed`);
|
|
467
487
|
return { content: [{ type: "text", text: result.text }] };
|
|
@@ -475,8 +495,33 @@ export function createPiDurableContact(options) {
|
|
|
475
495
|
let doorbell = null;
|
|
476
496
|
let report = { status: "off" };
|
|
477
497
|
const admissions = [];
|
|
478
|
-
|
|
498
|
+
/** The armed mailbox, kept past a retirement so notices admitted before it can still be judged. */
|
|
499
|
+
let inbox = null;
|
|
500
|
+
/** Every admitted notice still owed a withdraw judgement, oldest first. */
|
|
501
|
+
const notices = [];
|
|
502
|
+
let withdrawalFailure;
|
|
503
|
+
// One gate for ring passes and inbox reads; `pending` is a signal that landed while it was held.
|
|
504
|
+
let gated = false;
|
|
479
505
|
let pending = false;
|
|
506
|
+
const gateWaiters = [];
|
|
507
|
+
const enterGate = () => {
|
|
508
|
+
if (!gated) {
|
|
509
|
+
gated = true;
|
|
510
|
+
return Promise.resolve();
|
|
511
|
+
}
|
|
512
|
+
return new Promise((resolve) => gateWaiters.push(resolve));
|
|
513
|
+
};
|
|
514
|
+
/** Hand the gate to the next waiting read, else open it — and ring once for a signal that landed meanwhile. */
|
|
515
|
+
const leaveGate = () => {
|
|
516
|
+
const next = gateWaiters.shift();
|
|
517
|
+
if (next !== undefined) {
|
|
518
|
+
next();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
gated = false;
|
|
522
|
+
if (pending)
|
|
523
|
+
void ring("signal");
|
|
524
|
+
};
|
|
480
525
|
/**
|
|
481
526
|
* Give the doorbell back: close the watch and remove the marker, in one step, because a
|
|
482
527
|
* closed watch under a surviving marker is the false deliverability this unit refuses. Only a
|
|
@@ -520,20 +565,20 @@ export function createPiDurableContact(options) {
|
|
|
520
565
|
/**
|
|
521
566
|
* One contract with the other self-fetch doorbells: a fresh `*.msg` is the trigger, it is
|
|
522
567
|
* stamped `.msg.delivered` before the announce, and the announced count is every
|
|
523
|
-
* `.msg.delivered`. Events that land while an admission
|
|
524
|
-
* pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
|
|
525
|
-
* the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
|
|
526
|
-
* announced again on its own; it shows only in the count of the next fresh arrival.
|
|
568
|
+
* `.msg.delivered`. Events that land while an admission or an inbox read holds the gate coalesce
|
|
569
|
+
* into one more pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
|
|
570
|
+
* rings — the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
|
|
571
|
+
* not announced again on its own; it shows only in the count of the next fresh arrival.
|
|
527
572
|
*/
|
|
528
573
|
const ring = async (why) => {
|
|
529
574
|
const armed = doorbell;
|
|
530
575
|
if (armed === null)
|
|
531
576
|
return;
|
|
532
|
-
if (
|
|
577
|
+
if (gated) {
|
|
533
578
|
pending = true;
|
|
534
579
|
return;
|
|
535
580
|
}
|
|
536
|
-
|
|
581
|
+
gated = true;
|
|
537
582
|
try {
|
|
538
583
|
do {
|
|
539
584
|
pending = false;
|
|
@@ -568,13 +613,73 @@ export function createPiDurableContact(options) {
|
|
|
568
613
|
}
|
|
569
614
|
// Latest only: the diagnostic names the LAST success and never grows with the host's lifetime.
|
|
570
615
|
admissions.splice(0, admissions.length, { why, requestId, submissionId: submission.id, fresh, unread });
|
|
616
|
+
notices.push({ requestId, fresh, submission });
|
|
571
617
|
} while (pending && doorbell === armed);
|
|
572
618
|
}
|
|
573
619
|
catch (error) {
|
|
574
620
|
retire("doorbell-failed", describeError(error));
|
|
575
621
|
}
|
|
576
622
|
finally {
|
|
577
|
-
|
|
623
|
+
leaveGate();
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
/**
|
|
627
|
+
* One named stderr line per failed withdrawal — stderr ALWAYS, as `entwurf-control` does, since
|
|
628
|
+
* nothing in the native app reads the receiver report — and the report keeps the latest one.
|
|
629
|
+
*/
|
|
630
|
+
const noteWithdrawalFailure = (failure) => {
|
|
631
|
+
withdrawalFailure = failure;
|
|
632
|
+
const subject = failure.requestId === undefined ? "" : ` ${failure.requestId}`;
|
|
633
|
+
const detail = failure.detail.replace(/\s+/g, " ");
|
|
634
|
+
process.stderr.write(`[entwurf pi-durable] notice withdrawal ${failure.reason}${subject}: ${detail}\n`);
|
|
635
|
+
};
|
|
636
|
+
/**
|
|
637
|
+
* Withdraw every held notice none of whose announced `.msg` names is still `.msg.delivered` in the
|
|
638
|
+
* armed mailbox, listed once. A mailbox that cannot be listed proves nothing absent: every notice
|
|
639
|
+
* is kept and that is named. A judged notice is let go whatever the native withdraw answers — or
|
|
640
|
+
* when it rejects, which is named and never retried — so no handle outlives its mail by more
|
|
641
|
+
* than one read. Never throws: the read's own answer is what the caller gets.
|
|
642
|
+
*/
|
|
643
|
+
const withdrawDrained = async (context) => {
|
|
644
|
+
if (inbox === null || notices.length === 0)
|
|
645
|
+
return;
|
|
646
|
+
let unread;
|
|
647
|
+
try {
|
|
648
|
+
unread = new Set(fs.readdirSync(inbox).filter((name) => name.endsWith(".msg.delivered")));
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
noteWithdrawalFailure({ reason: "inspection-failed", detail: `${inbox}: ${describeError(error)}` });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
for (const notice of [...notices]) {
|
|
655
|
+
if (notice.fresh.some((name) => unread.has(`${name}.delivered`)))
|
|
656
|
+
continue;
|
|
657
|
+
notices.splice(notices.indexOf(notice), 1);
|
|
658
|
+
try {
|
|
659
|
+
await notice.submission.abort(context);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
noteWithdrawalFailure({ reason: "abort-failed", requestId: notice.requestId, detail: describeError(error) });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
/**
|
|
667
|
+
* An inbox read under the doorbell's gate, then the withdrawal its drain made due — after a
|
|
668
|
+
* failed read too, whose partial drain may already have archived mail. The bridge's own answer,
|
|
669
|
+
* result or rejection, is returned unchanged.
|
|
670
|
+
*/
|
|
671
|
+
const readInbox = async (call, context) => {
|
|
672
|
+
await enterGate();
|
|
673
|
+
try {
|
|
674
|
+
return await call();
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
try {
|
|
678
|
+
await withdrawDrained(context);
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
leaveGate();
|
|
682
|
+
}
|
|
578
683
|
}
|
|
579
684
|
};
|
|
580
685
|
/**
|
|
@@ -615,6 +720,7 @@ export function createPiDurableContact(options) {
|
|
|
615
720
|
receiversDir: roots.receiversDir,
|
|
616
721
|
});
|
|
617
722
|
doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
|
|
723
|
+
inbox = mailbox;
|
|
618
724
|
report = { status: "armed", gardenId, markerPath, admissions };
|
|
619
725
|
return markerPath;
|
|
620
726
|
}
|
|
@@ -731,13 +837,15 @@ export function createPiDurableContact(options) {
|
|
|
731
837
|
settleFailed(describeError(error));
|
|
732
838
|
},
|
|
733
839
|
receiver() {
|
|
734
|
-
return report;
|
|
840
|
+
return withdrawalFailure === undefined || report.status === "off" ? report : { ...report, withdrawalFailure };
|
|
735
841
|
},
|
|
736
842
|
async close() {
|
|
737
843
|
const previous = state;
|
|
738
844
|
settleFailed("the contact closed before attach completed");
|
|
739
845
|
state = { kind: "closed" };
|
|
740
846
|
const client = previous.kind === "ready" ? previous.client : undefined;
|
|
847
|
+
// Queued notices stay native (a continued session places them); this contact judges no more.
|
|
848
|
+
notices.length = 0;
|
|
741
849
|
await closeAll(disarmOnClose, async () => {
|
|
742
850
|
await client?.close();
|
|
743
851
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junghanacs/entwurf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"scripts/",
|
|
41
41
|
"prompts/",
|
|
42
42
|
"demo/README.md",
|
|
43
|
+
"demo/herdr-pi-cc-20261011.provenance.json",
|
|
44
|
+
"!demo/**/*.ts",
|
|
43
45
|
"demo/demo.sh",
|
|
44
46
|
"demo/demo-baseline.sh",
|
|
45
47
|
"docs/setup-clean-host.md",
|
|
@@ -186,7 +188,7 @@
|
|
|
186
188
|
"check:core": "pnpm run check:toolchain && pnpm run check:vitest && pnpm run check:contracts",
|
|
187
189
|
"check:toolchain": "pnpm lint && pnpm typecheck",
|
|
188
190
|
"check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-pi-queue-oracle && ./run.sh check-tests-beside-behavior",
|
|
189
|
-
"check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-acp-foreground-only && ./run.sh check-acp-vendor-rejection && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push && ./run.sh check-pi-durable-beside",
|
|
191
|
+
"check:contracts": "./run.sh check-demo-reproducibility && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-acp-foreground-only && ./run.sh check-acp-set-model-readback && ./run.sh check-acp-vendor-rejection && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push && ./run.sh check-pi-durable-beside",
|
|
190
192
|
"check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-pi-launch && ./run.sh check-codex-app-server-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-birth-hook && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-peer-facts && ./run.sh check-herdr-plugin && ./run.sh check-herdr-plugin-profile && ./run.sh check-herdr-plugin-build && ./run.sh check-herdr-runtime-bootstrap && ./run.sh check-herdr-activation && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-typing-call-fence && ./run.sh check-gate-manifests && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-codex-bridge-identity && ./run.sh check-pi-bridge-sender && ./run.sh check-pi-mcp-register && ./run.sh check-pi-mcp-bridge && ./run.sh check-codex-birth-hook && ./run.sh smoke-codex-config-state && ./run.sh smoke-codex-birth && ./run.sh check-herdr-sandbox",
|
|
191
193
|
"check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-pack-consumer-roots && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
|
|
192
194
|
},
|
|
@@ -339,6 +339,50 @@ function resolveClaudeLaunch(): AcpLaunchSpec {
|
|
|
339
339
|
*/
|
|
340
340
|
export const CLAUDE_FOREGROUND_ONLY_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" } as const;
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The set-model answer is read back before the prompt (#128). claude-agent-acp resolves the
|
|
344
|
+
* requested id onto one of the rows it offers and answers with that row's `value` as the model
|
|
345
|
+
* option's `currentValue` (0.85.1 `dist/acp-agent.js:5459-5499`). `[측정 2026-10-12]` 0.85.1, no
|
|
346
|
+
* prompt: `claude-fable-5-1` answers its own row, while `claude-sonnet-5-5` and `claude-opus-5-5`
|
|
347
|
+
* answer the alias rows `sonnet` and `opus`, named `Sonnet 5.5` and `Opus 5.5`. The id an alias row
|
|
348
|
+
* resolves to (`ModelInfo.resolvedModel`) is not on the ACP wire, so an alias answer is accepted
|
|
349
|
+
* only as the exact measured row, value AND name, declared here for the requested id. No other
|
|
350
|
+
* spelling is matched.
|
|
351
|
+
*/
|
|
352
|
+
export const CLAUDE_SET_MODEL_ALIAS_ROWS: Readonly<Record<string, Readonly<{ value: string; name: string }>>> =
|
|
353
|
+
Object.freeze({
|
|
354
|
+
"claude-sonnet-5-5": Object.freeze({ value: "sonnet", name: "Sonnet 5.5" }),
|
|
355
|
+
"claude-opus-5-5": Object.freeze({ value: "opus", name: "Opus 5.5" }),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/** Refuse unless the set-model answer names the requested model. A missing model option, another
|
|
359
|
+
* row, or a row that is not the measured alias row would let a different model answer under this id. */
|
|
360
|
+
export function confirmClaudeSetModelAnswer(response: unknown, nativeModelId: string): void {
|
|
361
|
+
const configOptions = (response as { configOptions?: unknown } | null | undefined)?.configOptions;
|
|
362
|
+
const modelOption = Array.isArray(configOptions)
|
|
363
|
+
? (configOptions as Array<Record<string, unknown> | null>).find((option) => option?.id === "model")
|
|
364
|
+
: undefined;
|
|
365
|
+
const currentValue = modelOption?.currentValue;
|
|
366
|
+
if (typeof currentValue === "string" && currentValue === nativeModelId) return;
|
|
367
|
+
const rows = Array.isArray(modelOption?.options)
|
|
368
|
+
? (modelOption.options as Array<Record<string, unknown> | null>).flatMap((row) =>
|
|
369
|
+
Array.isArray(row?.options) ? (row.options as Array<Record<string, unknown> | null>) : [row],
|
|
370
|
+
)
|
|
371
|
+
: [];
|
|
372
|
+
const selected = typeof currentValue === "string" ? rows.find((row) => row?.value === currentValue) : undefined;
|
|
373
|
+
const alias = CLAUDE_SET_MODEL_ALIAS_ROWS[nativeModelId];
|
|
374
|
+
if (alias !== undefined && currentValue === alias.value && selected?.name === alias.name) return;
|
|
375
|
+
const answered =
|
|
376
|
+
modelOption === undefined
|
|
377
|
+
? "no model option"
|
|
378
|
+
: `model ${jsonText(currentValue)} named ${jsonText(selected?.name ?? null)}`;
|
|
379
|
+
throw new Error(
|
|
380
|
+
`entwurf: claude-agent-acp answered set-model ${JSON.stringify(nativeModelId)} with ${answered}, which does not ` +
|
|
381
|
+
`name the requested model. Refusing before the prompt so a different model cannot answer under this id; ` +
|
|
382
|
+
`the installed driver and the curated id disagree, and repeating the turn cannot change that.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
342
386
|
export const claudeAdapter: AcpBackendAdapter = {
|
|
343
387
|
backend: "claude",
|
|
344
388
|
|
|
@@ -402,7 +446,12 @@ export const claudeAdapter: AcpBackendAdapter = {
|
|
|
402
446
|
if (typeof setConfig !== "function") {
|
|
403
447
|
throw new Error(`setSessionConfigOption unsupported — cannot enforce model ${modelId}`);
|
|
404
448
|
}
|
|
405
|
-
await setConfig.call(connection, {
|
|
449
|
+
const answer = await setConfig.call(connection, {
|
|
450
|
+
sessionId: acpSessionId,
|
|
451
|
+
configId: "model",
|
|
452
|
+
value: nativeModelId,
|
|
453
|
+
});
|
|
454
|
+
confirmClaudeSetModelAnswer(answer, nativeModelId);
|
|
406
455
|
},
|
|
407
456
|
|
|
408
457
|
configSignatureFields(_adapterSettings) {
|
|
@@ -566,7 +615,8 @@ export const cortexAdapter: AcpBackendAdapter = {
|
|
|
566
615
|
// serves fails loud BEFORE the prompt (`Unsupported model: …`). PR #40's
|
|
567
616
|
// launch-time `-m` pin was retired for this: set-model is the single model
|
|
568
617
|
// authority (resolveLaunch never passes `-m`), and "auto" is set explicitly
|
|
569
|
-
// rather than treated as an unspoken default.
|
|
618
|
+
// rather than treated as an unspoken default. NO answer readback, unlike claude: nobody has
|
|
619
|
+
// measured what cortex's set_config_option response carries, so a check would be a guess.
|
|
570
620
|
async enforceModel({ connection, acpSessionId, nativeModelId, modelId }) {
|
|
571
621
|
const setConfig = connection.setSessionConfigOption;
|
|
572
622
|
if (typeof setConfig !== "function") {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ACP plugin — the curated model IDs, and nothing else (#128).
|
|
2
|
+
//
|
|
3
|
+
// This leaf imports NOTHING. `models.ts` builds the registry rows from the pi-ai
|
|
4
|
+
// catalog; the launch seams (`fresh-call-dispatch.ts`, `resume-launch-identity.ts`)
|
|
5
|
+
// only need to know which IDs exist, and they ride the pi-free bridge closure, so
|
|
6
|
+
// they read the IDs here rather than load a vendor catalog to learn them.
|
|
7
|
+
//
|
|
8
|
+
// WHY `.js`: the same reason as `protocol.js`. Both the emit-capable root program
|
|
9
|
+
// (allowJs) and Node --experimental-strip-types resolve an explicit `.js` import
|
|
10
|
+
// literally to this file, so `models.ts` (root fence, `.js` specifiers) and the
|
|
11
|
+
// strip-types gates that load `models.ts` directly reach one SSOT. A `.ts` leaf
|
|
12
|
+
// would need a `.ts` specifier the root config cannot allow.
|
|
13
|
+
//
|
|
14
|
+
// WHY THE LAUNCH SEAMS ASK AT ALL. Pi's `--model` resolver falls back to a
|
|
15
|
+
// case-insensitive substring match among the provider's rows and prefers the
|
|
16
|
+
// lexically later id. Under one `entwurf` provider a retired `claude-sonnet-5`
|
|
17
|
+
// is a substring of `cortex-claude-sonnet-5`, so a launch Entwurf builds would
|
|
18
|
+
// silently open on the Snowflake Cortex rail instead of the Claude subscription.
|
|
19
|
+
// An exact match against this list is the only admission; nothing is aliased,
|
|
20
|
+
// substituted or rewritten.
|
|
21
|
+
|
|
22
|
+
/** The single ACP provider id under which every curated row is registered. */
|
|
23
|
+
export const PROVIDER_ID = "entwurf";
|
|
24
|
+
|
|
25
|
+
// The curated Claude ids. Adding one here is a commitment to verify it across
|
|
26
|
+
// both axes (protocol smoke + agent interview) — do not extend casually.
|
|
27
|
+
export const SUPPORTED_ANTHROPIC_MODEL_IDS = /** @type {const} */ ([
|
|
28
|
+
"claude-sonnet-5-5",
|
|
29
|
+
"claude-opus-5-5",
|
|
30
|
+
"claude-fable-5-1",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** The anchor model whose absence is a hard registry regression, not a soft skip. */
|
|
34
|
+
export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5-5";
|
|
35
|
+
|
|
36
|
+
// Cortex ids carry the reserved `cortex-` prefix; the rest of each id is the
|
|
37
|
+
// Snowflake-native model name, which is why Cortex still says `claude-opus-5`.
|
|
38
|
+
export const CORTEX_MODEL_PREFIX = "cortex-";
|
|
39
|
+
export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([
|
|
40
|
+
"cortex-auto",
|
|
41
|
+
"cortex-claude-opus-5",
|
|
42
|
+
"cortex-claude-sonnet-5",
|
|
43
|
+
"cortex-openai-gpt-5.4",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** @type {ReadonlySet<string>} */
|
|
47
|
+
const CURATED_ACP_MODEL_IDS = new Set([...SUPPORTED_ANTHROPIC_MODEL_IDS, ...SUPPORTED_CORTEX_MODEL_IDS]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exact membership only — no trim, no case folding, no prefix.
|
|
51
|
+
* @param {string} model
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isCuratedAcpModelId(model) {
|
|
55
|
+
return CURATED_ACP_MODEL_IDS.has(model);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The named refusal both launch seams answer with. */
|
|
59
|
+
export const ACP_MODEL_NOT_CURATED = /** @type {const} */ ("acp-model-not-curated");
|
|
60
|
+
|
|
61
|
+
export const ACP_MODEL_NOT_CURATED_HINT =
|
|
62
|
+
`an ${PROVIDER_ID} model must be one exact curated ACP id (${[...CURATED_ACP_MODEL_IDS].join(", ")}); ` +
|
|
63
|
+
"Pi would otherwise pick another row by substring — a retired claude-sonnet-5 lands on cortex-claude-sonnet-5 — " +
|
|
64
|
+
"so nothing was opened, and nothing is aliased or rewritten";
|
|
65
|
+
|
|
66
|
+
/** Carries `reason` so callers match a FIELD, never the wording. */
|
|
67
|
+
export class AcpModelNotCuratedError extends Error {
|
|
68
|
+
/** @param {string} model */
|
|
69
|
+
constructor(model) {
|
|
70
|
+
super(`${ACP_MODEL_NOT_CURATED}: ${JSON.stringify(model)} is not an exact curated ${PROVIDER_ID} model id`);
|
|
71
|
+
this.name = "AcpModelNotCuratedError";
|
|
72
|
+
/** @readonly */
|
|
73
|
+
this.reason = ACP_MODEL_NOT_CURATED;
|
|
74
|
+
/** @readonly */
|
|
75
|
+
this.model = model;
|
|
76
|
+
}
|
|
77
|
+
}
|