@junghanacs/entwurf 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/BASELINE.md +3 -1
- package/CHANGELOG.md +658 -0
- package/DELIVERY.md +167 -29
- package/README.md +104 -14
- package/VERIFY.md +73 -11
- package/docs/external-mcp-host.md +16 -6
- package/docs/setup-clean-host.md +73 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +289 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +171 -263
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -31
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +4 -4
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +257 -304
- package/run.sh +522 -10
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-facts.ts +12 -6
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-entwurf-v2-production.ts +42 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +19 -5
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launch-tmux.ts +331 -35
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-release-gate-outcomes.ts +12 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +121 -0
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +13 -5
- package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +334 -0
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +184 -25
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +22 -0
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-codex-config-state.sh +192 -3
- package/scripts/smoke-codex-fresh-live.ts +277 -37
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-setup-verdict.sh +13 -11
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mux-fresh-call — open ONE visible sibling in the caller's own tmux server (its own session by
|
|
3
3
|
* default, or one named existing session on that server since #105), hand it its first task in
|
|
4
|
-
* the launch argv, and let it name itself back to the caller. Codex is the one measured
|
|
5
|
-
* exception
|
|
4
|
+
* the launch argv, and let it name itself back to the caller. A Codex CALLER is the one measured
|
|
5
|
+
* exception to "the caller's own session": it opens beside its own TUI pane, found by that
|
|
6
|
+
* pane's terminal title (#95 lane B), never by a TUI guess.
|
|
6
7
|
*
|
|
7
8
|
* ── Why this is a third module and not a parameter on the leaf ──
|
|
8
9
|
*
|
|
@@ -39,6 +40,14 @@
|
|
|
39
40
|
* byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
|
|
40
41
|
* realpath, no project-name resolution, no store/peers/record lookup. The caller is the
|
|
41
42
|
* only cwd authority this module knows.
|
|
43
|
+
* - when a CODEX caller omits it, the surface supplies that citizen's own record cwd and it
|
|
44
|
+
* becomes an explicit `-c` (#95 lane C). This is not a second authority: it is the same
|
|
45
|
+
* caller, named more precisely, because a codex caller's PROCESS directory is the
|
|
46
|
+
* operator-owned app-server's rather than its own. Every other caller keeps the inherited
|
|
47
|
+
* directory and an unchanged argv.
|
|
48
|
+
* - the chosen value reaches codex TWICE, and that is one value with two carriers rather than
|
|
49
|
+
* two inputs: tmux `-c` places the pane, and codex `-C` places the THREAD, which a
|
|
50
|
+
* `--remote` attachment would otherwise take from the app-server (see `buildBackendArgs`).
|
|
42
51
|
* - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
|
|
43
52
|
* (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
|
|
44
53
|
* This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
|
|
@@ -77,14 +86,42 @@
|
|
|
77
86
|
* there is no "session created" field because nothing here creates one.
|
|
78
87
|
*/
|
|
79
88
|
|
|
80
|
-
import { randomBytes } from "node:crypto";
|
|
81
89
|
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
82
|
-
import {
|
|
90
|
+
import {
|
|
91
|
+
CODEX_CALLER_SEAT_HINT,
|
|
92
|
+
type CodexCallerSeatRejectReason,
|
|
93
|
+
resolveCodexCallerSeat,
|
|
94
|
+
} from "./codex-caller-seat.ts";
|
|
95
|
+
import {
|
|
96
|
+
CODEX_CALLER_PREFLIGHT_HINT,
|
|
97
|
+
CODEX_LAUNCH_CWD_PREFLIGHT_HINT,
|
|
98
|
+
CODEX_PREFLIGHT_HINT,
|
|
99
|
+
type CodexCallerPreflightRejectReason,
|
|
100
|
+
type CodexPreflightRejectReason,
|
|
101
|
+
codexLaunchCwdFreshPreflight,
|
|
102
|
+
} from "./codex-fresh-preflight.ts";
|
|
83
103
|
import {
|
|
84
104
|
COPILOT_PREFLIGHT_HINT,
|
|
85
105
|
type CopilotPreflightRejectReason,
|
|
86
106
|
copilotFreshPreflight,
|
|
87
107
|
} from "./copilot-fresh-preflight.ts";
|
|
108
|
+
import {
|
|
109
|
+
buildOmpBootstrapPayload,
|
|
110
|
+
composeBackendArgs,
|
|
111
|
+
composeFreshCallPrompt,
|
|
112
|
+
FRESH_CALL_BACKENDS,
|
|
113
|
+
FRESH_CALL_CALLBACK_TOOL,
|
|
114
|
+
type FreshCallBackend,
|
|
115
|
+
type FreshCallComposition,
|
|
116
|
+
type FreshCallInputRejectReason,
|
|
117
|
+
isSafeFreshCallModel,
|
|
118
|
+
MODEL_MAX_CHARS,
|
|
119
|
+
mintNonce,
|
|
120
|
+
normalizeFreshCallInputs,
|
|
121
|
+
OMP_BOOTSTRAP_FLAG,
|
|
122
|
+
OMP_BOOTSTRAP_VERSION,
|
|
123
|
+
TASK_MAX_CHARS,
|
|
124
|
+
} from "./fresh-call-composition.ts";
|
|
88
125
|
import {
|
|
89
126
|
assertLaunchTarget,
|
|
90
127
|
LaunchPreconditionError,
|
|
@@ -106,13 +143,69 @@ import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.ts"
|
|
|
106
143
|
import { OMP_PREFLIGHT_HINT, type OmpPreflightRejectReason, ompFreshPreflight } from "./omp-fresh-preflight.ts";
|
|
107
144
|
import { classifyTmuxSessionName, resolveTmuxSessionId, type TmuxSessionRejectReason } from "./resolve-tmux-session.ts";
|
|
108
145
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
/**
|
|
147
|
+
* THE COMPOSITION MOVED, THE RAIL DID NOT (#116 S2-a). Backend argv, the first-turn framing and
|
|
148
|
+
* the nonce now live in `fresh-call-composition.ts`, which knows nothing about tmux, so the herdr
|
|
149
|
+
* rail can reach them without importing this file and dragging placement behind it. They are
|
|
150
|
+
* re-exported here unchanged: every existing caller, gate and mutant anchor that reads them from
|
|
151
|
+
* this module keeps reading the same names.
|
|
152
|
+
*
|
|
153
|
+
* The two wrappers below are where this rail states what only it can state — the sentence that
|
|
154
|
+
* says a sibling was opened in the operator's tmux session, and the Codex socket path. Both are
|
|
155
|
+
* arguments to the leaf rather than knowledge inside it.
|
|
156
|
+
*/
|
|
157
|
+
export {
|
|
158
|
+
buildOmpBootstrapPayload,
|
|
159
|
+
FRESH_CALL_BACKENDS,
|
|
160
|
+
FRESH_CALL_CALLBACK_TOOL,
|
|
161
|
+
type FreshCallBackend,
|
|
162
|
+
type FreshCallComposition,
|
|
163
|
+
isSafeFreshCallModel,
|
|
164
|
+
MODEL_MAX_CHARS,
|
|
165
|
+
mintNonce,
|
|
166
|
+
OMP_BOOTSTRAP_FLAG,
|
|
167
|
+
OMP_BOOTSTRAP_VERSION,
|
|
168
|
+
TASK_MAX_CHARS,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** What the tmux rail tells a sibling about where it woke up. The resume verb has no equivalent
|
|
172
|
+
* because it composes no first turn at all; a herdr rail will pass its own sentence here. */
|
|
173
|
+
export const TMUX_FRESH_CALL_OPENING_LINE =
|
|
174
|
+
"You are a fresh visible citizen that entwurf opened in the operator's tmux session.";
|
|
175
|
+
|
|
176
|
+
/** Backend argv for THIS rail: the neutral dialect, with the two Codex facts only a rail can
|
|
177
|
+
* state — the socket resolved from the caller's env, and the directory this launch chose.
|
|
178
|
+
*
|
|
179
|
+
* `launchCwd` defaults to THIS process's own directory, which is what tmux gives a window opened
|
|
180
|
+
* with no `-c` (`[측정 ×2]`, module header). The default is that inherited fact rather than a
|
|
181
|
+
* convenience, so a caller that omits it still names the truth to codex. Both facts reach the
|
|
182
|
+
* leaf lazily, because a host with no Codex home must still be able to open pi and claude
|
|
183
|
+
* siblings — see `composeBackendArgs`. */
|
|
184
|
+
export function buildBackendArgs(
|
|
185
|
+
backend: FreshCallBackend,
|
|
186
|
+
composition: FreshCallComposition,
|
|
187
|
+
model: string,
|
|
188
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
189
|
+
launchCwd: string = process.cwd(),
|
|
190
|
+
): string[] {
|
|
191
|
+
return composeBackendArgs(
|
|
192
|
+
backend,
|
|
193
|
+
composition,
|
|
194
|
+
model,
|
|
195
|
+
() => resolveCodexDefaultSocketPath(env),
|
|
196
|
+
() => launchCwd,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The first turn for THIS rail: the neutral framing under this rail's placement sentence. */
|
|
201
|
+
export function buildFreshCallPrompt(params: {
|
|
202
|
+
backend: FreshCallBackend;
|
|
203
|
+
task: string;
|
|
204
|
+
callerGardenId: string;
|
|
205
|
+
nonce: string;
|
|
206
|
+
}): string {
|
|
207
|
+
return composeFreshCallPrompt({ ...params, openingLine: TMUX_FRESH_CALL_OPENING_LINE });
|
|
208
|
+
}
|
|
116
209
|
|
|
117
210
|
/**
|
|
118
211
|
* The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
|
|
@@ -144,246 +237,11 @@ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
|
|
|
144
237
|
codex: "codex",
|
|
145
238
|
};
|
|
146
239
|
|
|
147
|
-
/**
|
|
148
|
-
* The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
|
|
149
|
-
* capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
|
|
150
|
-
* name that harness composes. Naming the wrong one costs the whole first turn.
|
|
151
|
-
*
|
|
152
|
-
* `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
|
|
153
|
-
* `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
|
|
154
|
-
* (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
|
|
155
|
-
* `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
|
|
156
|
-
* `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
|
|
157
|
-
* sibling's spelling (`docs/adding-a-harness.md` step 5).
|
|
158
|
-
*
|
|
159
|
-
* `[측정]` omp 18.0.0 is the sharpest case for that rule: it mints
|
|
160
|
-
* `mcp__${sanitizedServerName}_${normalizedToolName}` with a sanitizer whose charset is
|
|
161
|
-
* `[a-z_]` (`mcp/tool-bridge.ts:351-357`, `:396`), so the DIGIT IN `entwurf_v2` IS EATEN and the
|
|
162
|
-
* hyphen in the server key becomes an underscore — the model-facing name is
|
|
163
|
-
* `mcp__entwurf_bridge_entwurf_v`, not `..._entwurf_v2` and not Claude's double-underscore form.
|
|
164
|
-
* Confirmed against a live tool dump of all seven bridge tools and a real session transcript
|
|
165
|
-
* (`scripts/raw-omp-measure/README.md` "Tool-name dialect"). Unlike Copilot there is no second
|
|
166
|
-
* permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
|
|
167
|
-
*/
|
|
168
|
-
export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
|
|
169
|
-
pi: "entwurf_v2",
|
|
170
|
-
"claude-code": "mcp__entwurf-bridge__entwurf_v2",
|
|
171
|
-
copilot: "entwurf-bridge-entwurf_v2",
|
|
172
|
-
omp: "mcp__entwurf_bridge_entwurf_v",
|
|
173
|
-
codex: "mcp__entwurf_bridge__entwurf_v2",
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
/** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
|
|
177
|
-
* delivery surface, not a claim that a task of this size was measured through tmux. An argv
|
|
178
|
-
* that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
|
|
179
|
-
export const TASK_MAX_CHARS = 16000;
|
|
180
|
-
export const MODEL_MAX_CHARS = 200;
|
|
181
|
-
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
|
|
182
|
-
|
|
183
|
-
/** A model is an explicit launch input, not ambient process state. The grammar admits canonical
|
|
184
|
-
* pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
|
|
185
|
-
* whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
|
|
186
|
-
* CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
|
|
187
|
-
export function isSafeFreshCallModel(model: string): boolean {
|
|
188
|
-
return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
|
|
193
|
-
* getting them wrong first (rail §6-a):
|
|
194
|
-
*
|
|
195
|
-
* pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
|
|
196
|
-
* Flag-first submitted no message; Pi rejects the equals form for `--model`.
|
|
197
|
-
* claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
|
|
198
|
-
* for allowedTools is variadic and eats the prompt as an option value.
|
|
199
|
-
* copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
|
|
200
|
-
* `--model`, value as two tokens, and the policy as the explicit `--yolo`
|
|
201
|
-
* token. Measured from `copilot --help` (1.0.80).
|
|
202
|
-
* omp — NO positional prompt at all. The task rides `--entwurf-bootstrap`, a fixed
|
|
203
|
-
* flag the installed birth extension registers, then `--model`, value as two
|
|
204
|
-
* tokens, then the policy as `--approval-mode`, `yolo`. `-p/--print` remains
|
|
205
|
-
* the flag this argv must never carry — it processes a prompt and EXITS,
|
|
206
|
-
* closing the window on a sibling that has to stay open to be delivered to.
|
|
207
|
-
*
|
|
208
|
-
* Both pi/claude failures looked identical from outside: window open, record and socket minted,
|
|
209
|
-
* no turn.
|
|
210
|
-
*
|
|
211
|
-
* For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
|
|
212
|
-
* the tool was already permitted, so the option's effect was unobservable. What was observed is
|
|
213
|
-
* that it does no harm to the argv. Permission stays a documented host precondition there.
|
|
214
|
-
*
|
|
215
|
-
* Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
|
|
216
|
-
* its argv facts are load-bearing:
|
|
217
|
-
*
|
|
218
|
-
* - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
|
|
219
|
-
* `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
|
|
220
|
-
* `scripts/copilot-launch.sh`.
|
|
221
|
-
* - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
|
|
222
|
-
* which would close the window on a sibling that is supposed to stay open and be delivered
|
|
223
|
-
* to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
|
|
224
|
-
* - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
|
|
225
|
-
* injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
|
|
226
|
-
* fresh composition to state its model and permission policy explicitly, never to rely
|
|
227
|
-
* invisibly on someone else's default.
|
|
228
|
-
*
|
|
229
|
-
* `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
|
|
230
|
-
* callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
|
|
231
|
-
* LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
|
|
232
|
-
* its task needed stopped on a confirmation prompt, which made the sibling impractical to work
|
|
233
|
-
* with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
|
|
234
|
-
* `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
|
|
235
|
-
* tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
|
|
236
|
-
* `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
|
|
237
|
-
* model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
|
|
238
|
-
* example; it is a measured vendor fact even though this argv no longer uses it.
|
|
239
|
-
*
|
|
240
|
-
* OMP'S POLICY TOKEN IS THE ONE MOST EASILY ARGUED AWAY, SO READ THIS BEFORE DELETING IT.
|
|
241
|
-
* `[측정]` omp 18.0.0's schema default for `tools.approvalMode` IS ALREADY `yolo`
|
|
242
|
-
* (vendor doc `omp://approval-mode.md`; `omp config get tools.approvalMode` → `yolo` on the
|
|
243
|
-
* acceptance host). So dropping `--approval-mode yolo` changes NOTHING observable: the callback
|
|
244
|
-
* still fires, the LIVE smoke still passes, and the argv silently starts depending on a vendor
|
|
245
|
-
* default and on whatever the operator's config happens to say. That is exactly the drift step 9
|
|
246
|
-
* clause 2 forbids — "carry the chosen width as an explicit argv token rather than relying on a
|
|
247
|
-
* launcher's injected default" — and the reason the width is stated here even though the host
|
|
248
|
-
* would have granted it anyway. The width itself (task-wide, not callback-only) is a GLG
|
|
249
|
-
* operator decision of 2026-08-30, taken with the Copilot measurement in hand: a callback-only
|
|
250
|
-
* sibling names itself and then stops at the first tool its TASK needs. omp offers no argv
|
|
251
|
-
* grammar for a narrower grant at all — `tools.approval.<tool>` is a config axis, not a flag —
|
|
252
|
-
* so the honest choice was between `write` and `yolo`, and `yolo` matches what a human-typed
|
|
253
|
-
* `omp` gets on this host. `--approval-mode` takes both the space and equals form (measured);
|
|
254
|
-
* the space form is used for symmetry with `--model`.
|
|
255
|
-
*
|
|
256
|
-
* WHY OMP ALONE CARRIES NO PROMPT, AND WHY THAT IS A MEASUREMENT RATHER THAN A PREFERENCE.
|
|
257
|
-
* `[LIVE 2026-08-30]` the first public fresh call at omp DID pass the full framing as a bare
|
|
258
|
-
* positional. The window opened, the record minted (garden `20260830T181342-452167`), the
|
|
259
|
-
* prompt arrived byte-identical as a user message at `09:13:42.413Z` — and the model answered
|
|
260
|
-
* the literal text `ACK` with ZERO tool calls, because the callback tool did not exist yet.
|
|
261
|
-
* `[source]` the interactive UI defers MCP discovery and only refreshes the tool list once
|
|
262
|
-
* `discoverAndConnect()` settles (`sdk.ts:1847-1855`, `:1881-1905`), while the positional
|
|
263
|
-
* `initialMessage` prompts immediately after `await mode.init()` (`main.ts:540-565`,
|
|
264
|
-
* `595-610`). `[측정]` a `/tmp` observer on the same runtime: `turn_start` at +654ms with the
|
|
265
|
-
* entwurf tools ABSENT, callback tool present only at +1484ms — the turn began ~830ms before
|
|
266
|
-
* the tool it was told to call existed. No argv can close that gap, because the gap is a race
|
|
267
|
-
* inside the host. So the composition hands omp a PAYLOAD instead of a turn, and the
|
|
268
|
-
* in-process birth extension — which can see when the tool becomes callable — owns the first
|
|
269
|
-
* two messages (`pi-extensions/meta-bridge-omp.ts`, "THE TWO-STAGE FRESH BOOTSTRAP").
|
|
270
|
-
*
|
|
271
|
-
* The flag is fixed and one-purpose ON PURPOSE. `[측정 2026-08-30]` a normal discovered
|
|
272
|
-
* extension that registers a flag receives the operator's argv value byte-identical — quotes,
|
|
273
|
-
* `$VAR`, backticks and a semicolon all survived a 137-byte JSON payload — because extensions
|
|
274
|
-
* load before argv classification and the reparse writes the registered map
|
|
275
|
-
* (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`). An env carrier or a temp file would
|
|
276
|
-
* have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
|
|
277
|
-
* all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
|
|
278
|
-
* carrier would hand callers the launch-shaping power this rail exists to refuse.
|
|
279
|
-
*/
|
|
280
|
-
export function buildBackendArgs(
|
|
281
|
-
backend: FreshCallBackend,
|
|
282
|
-
composition: FreshCallComposition,
|
|
283
|
-
model: string,
|
|
284
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
285
|
-
): string[] {
|
|
286
|
-
switch (backend) {
|
|
287
|
-
case "pi":
|
|
288
|
-
return [composition.prompt, "--entwurf-control", "--model", model];
|
|
289
|
-
case "claude-code":
|
|
290
|
-
return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
|
|
291
|
-
case "copilot":
|
|
292
|
-
return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
|
|
293
|
-
case "omp":
|
|
294
|
-
return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
|
|
295
|
-
case "codex":
|
|
296
|
-
return [
|
|
297
|
-
"--remote",
|
|
298
|
-
`unix://${resolveCodexDefaultSocketPath(env)}`,
|
|
299
|
-
"--model",
|
|
300
|
-
model,
|
|
301
|
-
"--dangerously-bypass-approvals-and-sandbox",
|
|
302
|
-
composition.prompt,
|
|
303
|
-
];
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* What a launch has to say, in the two shapes the five backends need. Four of them are
|
|
309
|
-
* handed a first-turn PROMPT; omp is handed a bootstrap PAYLOAD its own installed extension
|
|
310
|
-
* unpacks. Both are always built, because building one is cheap and a backend switch must
|
|
311
|
-
* never be able to reach a field that was not composed.
|
|
312
|
-
*/
|
|
313
|
-
export interface FreshCallComposition {
|
|
314
|
-
prompt: string;
|
|
315
|
-
bootstrapPayload: string;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
|
|
320
|
-
* name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
|
|
321
|
-
*
|
|
322
|
-
* Held equal to the installed extension's own constant by
|
|
323
|
-
* `test/omp-fresh-bootstrap.contract.test.ts`. The two copies exist because the extension
|
|
324
|
-
* ships INSIDE the omp agent dir carrying only its own small closure and cannot import this
|
|
325
|
-
* module; the gate is what keeps the duplication from becoming drift.
|
|
326
|
-
*/
|
|
327
|
-
export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
|
|
328
|
-
|
|
329
|
-
/** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
|
|
330
|
-
* unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
|
|
331
|
-
export const OMP_BOOTSTRAP_VERSION = 1;
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* The whole of what a fresh omp sibling is launched with.
|
|
335
|
-
*
|
|
336
|
-
* THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
|
|
337
|
-
* contract: who to call back, the nonce that proves it is this call, and the task that is
|
|
338
|
-
* released only after that callback succeeds. There is no command here, no path, no env name
|
|
339
|
-
* and no model — the model is already an explicit argv token, and a second copy of it inside
|
|
340
|
-
* a payload would be a second place for it to disagree with the launch.
|
|
341
|
-
*/
|
|
342
|
-
export function buildOmpBootstrapPayload(params: { callerGardenId: string; nonce: string; task: string }): string {
|
|
343
|
-
return JSON.stringify({
|
|
344
|
-
v: OMP_BOOTSTRAP_VERSION,
|
|
345
|
-
target: params.callerGardenId,
|
|
346
|
-
nonce: params.nonce,
|
|
347
|
-
task: params.task,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* The first-turn framing. Order is the contract: the callback is the FIRST action and the task
|
|
353
|
-
* follows it, so a sibling that gets stuck in the task has already told the caller who it is.
|
|
354
|
-
*
|
|
355
|
-
* The three prohibitions are not politeness. Each names a detour that was measured to produce a
|
|
356
|
-
* confidently wrong answer or a wasted turn.
|
|
357
|
-
*/
|
|
358
|
-
export function buildFreshCallPrompt(params: {
|
|
359
|
-
backend: FreshCallBackend;
|
|
360
|
-
task: string;
|
|
361
|
-
callerGardenId: string;
|
|
362
|
-
nonce: string;
|
|
363
|
-
}): string {
|
|
364
|
-
const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
|
|
365
|
-
return [
|
|
366
|
-
"You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
|
|
367
|
-
"",
|
|
368
|
-
`FIRST ACTION, before reading files or anything else: call ${tool} with ` +
|
|
369
|
-
`target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
|
|
370
|
-
`message set to exactly ${params.nonce} — that string alone, nothing added.`,
|
|
371
|
-
"That call is how the agent that opened you learns your address. Do not skip it, do not",
|
|
372
|
-
"defer it until the task is done, and do not reword the message.",
|
|
373
|
-
"",
|
|
374
|
-
"Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
|
|
375
|
-
"server yourself. Your own report of your identity is not the address anyone needs.",
|
|
376
|
-
"",
|
|
377
|
-
"After the tool receipt, carry out this task:",
|
|
378
|
-
"",
|
|
379
|
-
params.task,
|
|
380
|
-
].join("\n");
|
|
381
|
-
}
|
|
382
|
-
|
|
383
240
|
/** A launch that was refused, or a placement that could not be established. Every value is a
|
|
384
241
|
* NAMED refusal — this module has no fallback launch and no fallback directory. The cwd members
|
|
385
242
|
* come from the shared classification leaf and their string values are stable contract. */
|
|
386
243
|
export type FreshCallRejectReason =
|
|
244
|
+
| FreshCallInputRejectReason
|
|
387
245
|
| PlacementRejectReason
|
|
388
246
|
| LaunchRejectReason
|
|
389
247
|
| TmuxCwdRejectReason
|
|
@@ -391,6 +249,8 @@ export type FreshCallRejectReason =
|
|
|
391
249
|
| CopilotPreflightRejectReason
|
|
392
250
|
| OmpPreflightRejectReason
|
|
393
251
|
| CodexPreflightRejectReason
|
|
252
|
+
| CodexCallerPreflightRejectReason
|
|
253
|
+
| CodexCallerSeatRejectReason
|
|
394
254
|
| "caller-identity-unavailable"
|
|
395
255
|
| "model-empty"
|
|
396
256
|
| "model-invalid"
|
|
@@ -405,25 +265,41 @@ export interface FreshCallPlacement {
|
|
|
405
265
|
tmuxSession: string;
|
|
406
266
|
}
|
|
407
267
|
|
|
408
|
-
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
* convention, not an address axis — `threadId` remains the native delivery address.
|
|
268
|
+
/** Which rule chose the start directory. Mirrors `FreshCallSeatSource` deliberately: one axis,
|
|
269
|
+
* one NAMED source, and no third value that means "we worked it out". `codex-caller-record` is
|
|
270
|
+
* the codex caller's own record cwd, consulted only when the caller requested none (#95 lane C).
|
|
412
271
|
*/
|
|
413
|
-
export
|
|
414
|
-
|
|
272
|
+
export type FreshCallCwdSource = "requested" | "codex-caller-record";
|
|
273
|
+
|
|
274
|
+
export type FreshCallSeatSource = "requested" | "codex-title-anchor";
|
|
415
275
|
export interface FreshCallSeat {
|
|
416
276
|
tmuxSession: string;
|
|
417
277
|
source: FreshCallSeatSource;
|
|
418
278
|
}
|
|
419
279
|
|
|
420
|
-
/**
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
280
|
+
/**
|
|
281
|
+
* WHICH RULE PICKS THE TARGET SESSION. Three in strict order, and the order is the contract:
|
|
282
|
+
*
|
|
283
|
+
* 1. an explicit `placement` — the expert override, and it wins over everything below.
|
|
284
|
+
* 2. a CODEX CALLER with placement omitted — the sibling opens beside the caller's own TUI
|
|
285
|
+
* pane, found by the title anchor (#95 lane B). This one is NOT a named seat and is
|
|
286
|
+
* therefore not decided here: it resolves to a native `$id` with no name in between, so
|
|
287
|
+
* `freshCall` owns it directly (see the anchor step below) and this function answers
|
|
288
|
+
* `null` so the name grammar and the name→id lookup stay out of a path that has neither.
|
|
289
|
+
* 3. anything else — the caller's own session, with no named seat at all.
|
|
290
|
+
*
|
|
291
|
+
* THE SEAT FOLLOWS THE CALLER, NEVER THE BACKEND BEING OPENED. #95 first shipped a fourth rule —
|
|
292
|
+
* an omitted-placement Codex TARGET selected a fixed existing session named `codex`, where the
|
|
293
|
+
* operator seated the app-server and their TUIs — and #95 D1 retired it (GLG, 2026-09-16). It
|
|
294
|
+
* was a workaround for a mapping that did not exist yet: nothing could find the pane a Codex
|
|
295
|
+
* caller was sitting in, so the operator was asked to keep every Codex in one known room. Rule 2
|
|
296
|
+
* is that mapping, so the room is no longer load-bearing, and keeping it would have meant Codex
|
|
297
|
+
* alone answering "where does a sibling open?" differently from every other backend. The
|
|
298
|
+
* operator-owned app-server keeps its own seat wherever the operator puts it; Entwurf still
|
|
299
|
+
* never creates, moves or supervises it.
|
|
300
|
+
*/
|
|
301
|
+
export function selectFreshCallSeat(placement: FreshCallPlacement | undefined): FreshCallSeat | null {
|
|
425
302
|
if (placement !== undefined) return { tmuxSession: placement.tmuxSession, source: "requested" };
|
|
426
|
-
if (backend === "codex") return { tmuxSession: CODEX_HOME_TMUX_SESSION, source: "codex-home" };
|
|
427
303
|
return null;
|
|
428
304
|
}
|
|
429
305
|
|
|
@@ -433,14 +309,21 @@ export function selectFreshCallSeat(
|
|
|
433
309
|
export interface FreshCallReceipt extends WindowHandle {
|
|
434
310
|
backend: FreshCallBackend;
|
|
435
311
|
model: string;
|
|
436
|
-
/** The
|
|
437
|
-
*
|
|
438
|
-
*
|
|
312
|
+
/** The start directory this launch CHOSE — present only when one was chosen: the caller's
|
|
313
|
+
* requested cwd, or a codex caller's own record cwd when it requested none. The same kind of
|
|
314
|
+
* fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
|
|
315
|
+
* landed. ABSENT means no directory was named at all and the pane inherits this process's —
|
|
316
|
+
* that inheritance is not invented into a receipt field here. */
|
|
439
317
|
cwd?: string;
|
|
440
|
-
/**
|
|
441
|
-
|
|
318
|
+
/** Which rule chose `cwd`. Present exactly when `cwd` is. */
|
|
319
|
+
cwdSource?: FreshCallCwdSource;
|
|
320
|
+
/** The caller-REQUESTED session name. The RESOLVED target is the inherited `sessionId`, which
|
|
321
|
+
* is the session the window is actually in. Absent for `codex-title-anchor`, which has no
|
|
322
|
+
* name to echo: a caller's own pane was OBSERVED, not requested by name, and inventing one
|
|
323
|
+
* here would report a seat the caller never named. */
|
|
442
324
|
tmuxSession?: string;
|
|
443
|
-
/**
|
|
325
|
+
/** Which rule selected the target session. Absent exactly when the caller's own session was
|
|
326
|
+
* used with no seat rule at all. */
|
|
444
327
|
tmuxSessionSource?: FreshCallSeatSource;
|
|
445
328
|
runtimePath: string;
|
|
446
329
|
nonce: string;
|
|
@@ -448,16 +331,6 @@ export interface FreshCallReceipt extends WindowHandle {
|
|
|
448
331
|
|
|
449
332
|
export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: false; reason: FreshCallRejectReason };
|
|
450
333
|
|
|
451
|
-
/** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
|
|
452
|
-
* encoded any of those would invite exactly the guessing this rail exists to refuse. */
|
|
453
|
-
export function mintNonce(randomHex: () => string = defaultRandomHex): string {
|
|
454
|
-
return `mux-fresh-call-${randomHex()}`;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function defaultRandomHex(): string {
|
|
458
|
-
return randomBytes(12).toString("hex");
|
|
459
|
-
}
|
|
460
|
-
|
|
461
334
|
/**
|
|
462
335
|
* The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
|
|
463
336
|
*
|
|
@@ -531,6 +404,20 @@ export function buildFreshCallArgs(
|
|
|
531
404
|
* `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
|
|
532
405
|
* record-backed context. It is not a tool parameter and this module never derives, validates
|
|
533
406
|
* against a store, or guesses it: an empty value is a named refusal, not a lookup.
|
|
407
|
+
*
|
|
408
|
+
* `callerNativeSessionId` is supplied by the same surface under the same rule, and its PRESENCE
|
|
409
|
+
* is the whole signal: it is set exactly when the reconciled sender is a record-backed codex
|
|
410
|
+
* citizen, and it carries that citizen's `nativeSessionId` (the `_meta.threadId` the vendor put
|
|
411
|
+
* on this very request). This module never resolves it, never reads `_meta`, and never asks a
|
|
412
|
+
* store who is calling — it only turns a thread id into a pane, and only for placement.
|
|
413
|
+
*
|
|
414
|
+
* `callerCwd` rides the same surface rule and the same condition: it is that codex citizen's
|
|
415
|
+
* RECORD cwd, and it exists because a codex caller's process directory is NOT its own (the
|
|
416
|
+
* bridge runs as a child of the operator-owned app-server, so the directory that process
|
|
417
|
+
* reports is the app-server's — #95 lane C §2). It is consulted only when the caller requested
|
|
418
|
+
* no cwd, so an
|
|
419
|
+
* explicit request always wins, and this module never looks a cwd up, resolves it, or infers it
|
|
420
|
+
* from a seat, a workspace map or a project name.
|
|
534
421
|
*/
|
|
535
422
|
export function freshCall(
|
|
536
423
|
params: {
|
|
@@ -540,23 +427,38 @@ export function freshCall(
|
|
|
540
427
|
cwd?: string;
|
|
541
428
|
placement?: FreshCallPlacement;
|
|
542
429
|
callerGardenId: string | null;
|
|
430
|
+
callerNativeSessionId?: string;
|
|
431
|
+
callerCwd?: string;
|
|
543
432
|
},
|
|
544
433
|
env: NodeJS.ProcessEnv = process.env,
|
|
545
434
|
nonce: string = mintNonce(),
|
|
546
435
|
): FreshCallResult {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (!
|
|
553
|
-
const task =
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
const
|
|
436
|
+
// The caller-facing input contract lives in the composition leaf so BOTH rails answer a
|
|
437
|
+
// mistyped model or an oversized task with the same words. Order, trimming and the
|
|
438
|
+
// cwd-omission rule (`undefined` and the exact empty string, and nothing else, mean "no
|
|
439
|
+
// cwd") are unchanged from when they lived here.
|
|
440
|
+
const normalized = normalizeFreshCallInputs(params);
|
|
441
|
+
if (!normalized.ok) return { ok: false, reason: normalized.reason };
|
|
442
|
+
const { callerGardenId, model, task, cwd: requestedCwd } = normalized.inputs;
|
|
443
|
+
// The caller's own record directory answers the SAME two-value emptiness rule the leaf
|
|
444
|
+
// applies to a requested cwd, and is consulted ONLY second: an explicit request always wins,
|
|
445
|
+
// and a caller that supplies neither leaves the pane to inherit this process's directory
|
|
446
|
+
// exactly as before — a pi caller's argv is byte-identical, because its process directory IS
|
|
447
|
+
// its own and a `-c` token would change nothing about where that window lands.
|
|
448
|
+
const callerCwd = params.callerCwd === undefined || params.callerCwd === "" ? undefined : params.callerCwd;
|
|
449
|
+
const chosenCwd: { value: string; source: FreshCallCwdSource } | undefined =
|
|
450
|
+
requestedCwd !== undefined
|
|
451
|
+
? { value: requestedCwd, source: "requested" }
|
|
452
|
+
: callerCwd !== undefined
|
|
453
|
+
? { value: callerCwd, source: "codex-caller-record" }
|
|
454
|
+
: undefined;
|
|
455
|
+
const cwd = chosenCwd?.value;
|
|
456
|
+
// SEAM (#95 lane C): a caller-record directory is classified by the SAME shared leaf and
|
|
457
|
+
// answers the same four `cwd-*` reasons, whose hint text says REQUESTED. When a codex
|
|
458
|
+
// caller's recorded directory has since been deleted, the repair that hint points at is
|
|
459
|
+
// still the right one — that directory does not exist — but the noun belongs to the caller
|
|
460
|
+
// rather than to the request. Kept shared on purpose: doubling the reason set for a wording
|
|
461
|
+
// difference would double the refusal contract two surfaces and one leaf already agree on.
|
|
560
462
|
if (cwd !== undefined) {
|
|
561
463
|
const badCwd = classifyTmuxCwd(cwd);
|
|
562
464
|
if (badCwd) return { ok: false, reason: badCwd };
|
|
@@ -565,9 +467,10 @@ export function freshCall(
|
|
|
565
467
|
// without tmux, so an unresolvable name is answered before anything else runs. Whether that
|
|
566
468
|
// session EXISTS is a tmux question and is asked below, after the caller's own context is
|
|
567
469
|
// proven — a name check that needed a live server would refuse for the wrong reason on a
|
|
568
|
-
// host with no tmux at all.
|
|
569
|
-
//
|
|
570
|
-
|
|
470
|
+
// host with no tmux at all. An explicit seat is an expert override and is reported as such; a
|
|
471
|
+
// CODEX CALLER's own pane is deliberately not a name at all, so it is absent here and
|
|
472
|
+
// resolved after the context proof below — see `selectFreshCallSeat` for the three-rule order.
|
|
473
|
+
const selectedSeat = selectFreshCallSeat(params.placement);
|
|
571
474
|
const seat = selectedSeat?.tmuxSession;
|
|
572
475
|
if (seat !== undefined) {
|
|
573
476
|
const badSeat = classifyTmuxSessionName(seat);
|
|
@@ -606,25 +509,67 @@ export function freshCall(
|
|
|
606
509
|
// native id continues; the name does not travel past this line. STILL PRE-MUTATION: an
|
|
607
510
|
// absent seat refuses with no window anywhere.
|
|
608
511
|
let targetSessionId = placement.sessionId;
|
|
512
|
+
let anchoredSeat = false;
|
|
609
513
|
if (seat !== undefined) {
|
|
610
514
|
const resolved = resolveTmuxSessionId(seat, (args) => runTmux(args, env));
|
|
611
515
|
if (!resolved.ok) return { ok: false, reason: resolved.reason };
|
|
612
516
|
targetSessionId = resolved.sessionId;
|
|
517
|
+
} else if (params.placement === undefined && params.callerNativeSessionId !== undefined) {
|
|
518
|
+
// Rule 2: the caller is a codex citizen and named no seat, so the sibling belongs beside
|
|
519
|
+
// the caller's own TUI. The pane is found by the title anchor and ONLY its `$session`
|
|
520
|
+
// continues — a pane title is forgeable, so it may never become an address, a liveness
|
|
521
|
+
// claim or a delivery input (Hard Rule 16). `params.placement` is re-read here rather
|
|
522
|
+
// than inferred from `seat === undefined`: "an explicit seat always wins" is the one
|
|
523
|
+
// invariant a later edit must not be able to lose by accident.
|
|
524
|
+
// STILL PRE-MUTATION: 0 or 2+ matching panes refuse with no window anywhere and no
|
|
525
|
+
// fallback to any other session.
|
|
526
|
+
const anchor = resolveCodexCallerSeat(params.callerNativeSessionId, (args) => runTmux(args, env));
|
|
527
|
+
if (!anchor.ok) return { ok: false, reason: anchor.reason };
|
|
528
|
+
targetSessionId = anchor.seat.sessionId;
|
|
529
|
+
anchoredSeat = true;
|
|
613
530
|
}
|
|
614
531
|
|
|
615
532
|
const composition: FreshCallComposition = {
|
|
616
533
|
prompt: buildFreshCallPrompt({
|
|
617
534
|
backend: params.backend,
|
|
618
535
|
task,
|
|
619
|
-
callerGardenId
|
|
536
|
+
callerGardenId,
|
|
620
537
|
nonce,
|
|
621
538
|
}),
|
|
622
|
-
bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId
|
|
539
|
+
bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId, nonce, task }),
|
|
623
540
|
};
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
541
|
+
const backendArgs = buildBackendArgs(params.backend, composition, model, env, cwd);
|
|
542
|
+
// THE LAUNCH-DIRECTORY NOTE, AND IT IS A DIAGNOSTIC RATHER THAN A GATE. `[측정 2026-09-16]` a
|
|
543
|
+
// Codex sibling opened into a directory this Codex has no answer for stops on the vendor's
|
|
544
|
+
// folder-consent screen: no first turn, no rollout, no callback. It is tempting to refuse
|
|
545
|
+
// that, and refusing is the wrong product. The consent screen is SELF-REPAIRING when a human
|
|
546
|
+
// is there — one answer and the vendor records the directory, so every later launch runs —
|
|
547
|
+
// and an operator at the keyboard is exactly who a visible-first rail is built for. A refusal
|
|
548
|
+
// would replace that one answer with "no window, go run codex yourself, then call again", and
|
|
549
|
+
// it would have to be right about a decision this process cannot fully see (the vendor merges
|
|
550
|
+
// system, managed and cloud layers around the file this leaf reads). So the launch proceeds
|
|
551
|
+
// and says what it saw.
|
|
552
|
+
//
|
|
553
|
+
// The UNATTENDED case is not answered here and must not be: a gate with nobody at the keyboard
|
|
554
|
+
// needs its precondition named before it spends a model turn, which is its own oracle's job —
|
|
555
|
+
// `smoke-codex-fresh-live` asserts this same leaf up front, so a missing answer reads as a
|
|
556
|
+
// named precondition instead of a callback timeout.
|
|
557
|
+
//
|
|
558
|
+
// The directory asked about is READ BACK off codex's own `-C` token rather than recomputed:
|
|
559
|
+
// one resolution, one authority, and no way for the note to name a directory the thread will
|
|
560
|
+
// not start in (this module is deliberately not allowed to resolve the inherited default a
|
|
561
|
+
// second time — `FRESHCALL-CWD-CALLER-ONLY`).
|
|
562
|
+
if (params.backend === "codex") {
|
|
563
|
+
const at = backendArgs.indexOf("-C");
|
|
564
|
+
const launchCwd = backendArgs[at + 1] ?? "";
|
|
565
|
+
const unanswered = codexLaunchCwdFreshPreflight(env, launchCwd);
|
|
566
|
+
if (unanswered) {
|
|
567
|
+
console.error(
|
|
568
|
+
`[fresh-call] ${unanswered}: ${launchCwd}\n` + ` ${CODEX_LAUNCH_CWD_PREFLIGHT_HINT[unanswered]}`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, backendArgs, cwd), env);
|
|
628
573
|
assertTmuxOk("new-window", run);
|
|
629
574
|
|
|
630
575
|
let fields: ReturnType<typeof parseWindowFields>;
|
|
@@ -649,9 +594,11 @@ export function freshCall(
|
|
|
649
594
|
...fields,
|
|
650
595
|
backend: params.backend,
|
|
651
596
|
model,
|
|
652
|
-
...(
|
|
597
|
+
...(chosenCwd === undefined ? {} : { cwd: chosenCwd.value, cwdSource: chosenCwd.source }),
|
|
653
598
|
...(selectedSeat === null
|
|
654
|
-
?
|
|
599
|
+
? anchoredSeat
|
|
600
|
+
? { tmuxSessionSource: "codex-title-anchor" as const }
|
|
601
|
+
: {}
|
|
655
602
|
: { tmuxSession: selectedSeat.tmuxSession, tmuxSessionSource: selectedSeat.source }),
|
|
656
603
|
runtimePath,
|
|
657
604
|
nonce,
|
|
@@ -667,6 +614,8 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
667
614
|
...COPILOT_PREFLIGHT_HINT,
|
|
668
615
|
...OMP_PREFLIGHT_HINT,
|
|
669
616
|
...CODEX_PREFLIGHT_HINT,
|
|
617
|
+
...CODEX_CALLER_PREFLIGHT_HINT,
|
|
618
|
+
...CODEX_CALLER_SEAT_HINT,
|
|
670
619
|
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
671
620
|
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
672
621
|
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
@@ -683,7 +632,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
683
632
|
"tmux-session-name-invalid":
|
|
684
633
|
"the requested tmux session name is outside the shape this rail addresses (start with a letter or digit, then letters, digits, '_' or '-') — some other shapes tmux cannot resolve at all ('#' is expanded when the name is stored; '.' and ':' are its own pane/window separators inside a target; a name like '$0' loses to the session id '$0'), and the rest are declined to keep one narrow grammar, so rename the session or open one whose name fits",
|
|
685
634
|
"tmux-session-missing":
|
|
686
|
-
"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
|
|
635
|
+
"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",
|
|
687
636
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
688
637
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
689
638
|
"task-empty": "task is empty after trimming",
|
|
@@ -718,11 +667,15 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
|
|
|
718
667
|
`[entwurf fresh call →]\n` +
|
|
719
668
|
` backend: ${r.backend} (${r.runtimePath})\n` +
|
|
720
669
|
` model: ${r.model} (requested on the runtime CLI)\n` +
|
|
721
|
-
(r.cwd === undefined
|
|
722
|
-
|
|
670
|
+
(r.cwd === undefined
|
|
671
|
+
? ""
|
|
672
|
+
: r.cwdSource === "codex-caller-record"
|
|
673
|
+
? ` cwd: ${r.cwd} (the Codex caller's own record directory, used because no cwd was requested — not an observation)\n`
|
|
674
|
+
: ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
|
|
675
|
+
(r.tmuxSessionSource === undefined
|
|
723
676
|
? ""
|
|
724
|
-
: r.tmuxSessionSource === "codex-
|
|
725
|
-
? ` seat: ${r.
|
|
677
|
+
: r.tmuxSessionSource === "codex-title-anchor"
|
|
678
|
+
? ` seat: ${r.sessionId} (the Codex caller's own pane, found by its thread-id terminal title — an OBSERVED session, not a requested name)\n`
|
|
726
679
|
: ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
|
|
727
680
|
` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
|
|
728
681
|
` pane: ${r.paneId} pid ${r.panePid}\n` +
|