@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +7 -0
  3. package/VERIFY.md +16 -5
  4. package/demo/README.md +109 -175
  5. package/demo/demo-baseline.sh +5 -1
  6. package/demo/demo.sh +6 -2
  7. package/demo/herdr-pi-cc-20261011.provenance.json +64 -0
  8. package/docs/acp-backend-rail.md +28 -2
  9. package/docs/assets/entwurf-demo.gif +0 -0
  10. package/docs/setup-clean-host.md +1 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +119 -11
  20. package/package.json +4 -2
  21. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  22. package/pi-extensions/lib/acp/model-ids.js +77 -0
  23. package/pi-extensions/lib/acp/models.ts +15 -22
  24. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  25. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  26. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  27. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  28. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  29. package/pi-extensions/meta-bridge-pi-durable.ts +153 -13
  30. package/run.sh +118 -20
  31. package/scripts/check-acp-backend-preflight.ts +10 -2
  32. package/scripts/check-acp-cortex.ts +7 -6
  33. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  34. package/scripts/check-acp-provider-surface.ts +1 -1
  35. package/scripts/check-acp-session-reuse.ts +5 -5
  36. package/scripts/check-acp-stop-reason.ts +2 -2
  37. package/scripts/check-acp-stream-hooks.ts +2 -2
  38. package/scripts/check-acp-usage-accounting.ts +2 -2
  39. package/scripts/check-fresh-call-dispatch.ts +47 -4
  40. package/scripts/check-gate-qualification.ts +8 -5
  41. package/scripts/check-probe-ordering.ts +13 -6
  42. package/scripts/check-release-gate-outcomes.ts +159 -1
  43. package/scripts/check-resume-launch-identity.ts +38 -0
  44. package/scripts/ci-qualify-decide.sh +114 -17
  45. package/scripts/mutants/acp-cortex.json +17 -8
  46. package/scripts/mutants/acp-set-model-readback.json +28 -0
  47. package/scripts/mutants/demo-reproducibility.json +155 -0
  48. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  49. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  50. package/scripts/mutants/pi-durable.json +117 -0
  51. package/scripts/mutants/release-gate.json +28 -2
  52. package/scripts/mutants/resume-launch-identity.json +15 -0
  53. package/scripts/mutants/v2-visible-resume.json +11 -0
  54. package/scripts/resolve-acp-bridge.ts +1 -1
  55. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  56. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  57. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  58. package/scripts/smoke-acp-mcp-live.ts +1 -1
  59. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  60. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  61. package/scripts/smoke-acp-overlay-live.ts +1 -1
  62. package/scripts/smoke-acp-provider-live.ts +1 -1
  63. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  64. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  65. package/scripts/smoke-acp-skill-live.ts +1 -1
  66. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  67. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  68. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  69. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  70. 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
- /** Provider id — current pre-rename surface; S1 renames this load-bearing id to `entwurf`. */
36
- export const PROVIDER_ID = "entwurf";
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
- // The curated Claude ids. Adding one here is a commitment to verify it across
49
- // both axes (protocol smoke + agent interview) — do not extend casually.
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
- * The ONE expected refusal on this leaf: the target is a citizen of a backend that has no
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: explicitExtensions.provider ?? identity.provider,
134
- model: explicitExtensions.modelOverride ?? resumeModel,
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. No resume.
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 result = await client.callTool(name, (args ?? {}), context?.abortSignal);
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
- let ringing = false;
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 is in flight coalesce into one more
524
- * pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg` rings —
525
- * the startup pass included — so a `.msg.delivered` left undrained by an earlier host is not
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 (ringing) {
577
+ if (gated) {
533
578
  pending = true;
534
579
  return;
535
580
  }
536
- ringing = true;
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
- ringing = false;
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.34.2",
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, { sessionId: acpSessionId, configId: "model", value: nativeModelId });
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
+ }