projmux 0.12.2 → 0.13.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/docs/agent-workflow.md +183 -20
- package/docs/ai-agent-shortcuts.md +22 -3
- package/docs/architecture.md +1291 -90
- package/docs/cli-guide.md +499 -57
- package/docs/cli.md +236 -64
- package/docs/configuration.md +197 -35
- package/docs/hooks.md +17 -3
- package/docs/keybindings.md +2 -2
- package/docs/legacy-cli-retirement.md +37 -8
- package/docs/operational-diagnostics.md +58 -2
- package/docs/session-restore.md +73 -157
- package/docs/settings-ia.md +48 -24
- package/docs/statusbar.md +10 -2
- package/docs/testing.md +80 -4
- package/docs/upgrading.md +243 -5
- package/docs/usage-tracking.md +56 -10
- package/package.json +5 -5
package/docs/architecture.md
CHANGED
|
@@ -39,6 +39,40 @@ Responsibilities:
|
|
|
39
39
|
- parse command output
|
|
40
40
|
- convert failures into typed errors
|
|
41
41
|
|
|
42
|
+
#### Codex app-server compatibility and lifecycle bridge
|
|
43
|
+
|
|
44
|
+
`internal/integrations/agents/codexappserver` is a Codex-only vertical slice.
|
|
45
|
+
It owns the headerless JSON-RPC request, response, and notification wire types,
|
|
46
|
+
the newline-delimited direct-stdio framing limit, request IDs,
|
|
47
|
+
initialize/initialized handshake, local cancellation, and connection
|
|
48
|
+
replacement. The local proxy transport performs the required HTTP Upgrade and
|
|
49
|
+
bounded RFC6455 WebSocket framing (including masked client frames) before
|
|
50
|
+
carrying those JSON-RPC messages. Core metadata and UI packages receive only
|
|
51
|
+
its closed, content-free health result; they do not import app-server request
|
|
52
|
+
or event types.
|
|
53
|
+
|
|
54
|
+
The compatibility probe runs the fixed read-only bridge `codex app-server
|
|
55
|
+
proxy` against the local control socket and sends only `initialize` plus
|
|
56
|
+
`initialized`. Doctor, Settings, and support-report triggers remain probe-only
|
|
57
|
+
and never mutate daemon state. A future native user-action trigger may enter the
|
|
58
|
+
lifecycle seam, but only the exact closed `daemon-not-running` classification
|
|
59
|
+
(the official local socket is missing or refuses a local connection) may invoke
|
|
60
|
+
the installed CLI's idempotent `codex app-server daemon start`, at most once for
|
|
61
|
+
the shared in-flight attempt in this process. The start and readiness retry are
|
|
62
|
+
bounded, each caller can cancel its own wait, and readiness must still complete
|
|
63
|
+
the proxy initialize handshake. All other executable, timeout, unsupported,
|
|
64
|
+
protocol, and endpoint failures stay on the existing fallback without a start
|
|
65
|
+
attempt.
|
|
66
|
+
|
|
67
|
+
The bridge discards command output and reports only closed, content-free health
|
|
68
|
+
and lifecycle reasons; prompts, tokens, paths, and process output do not cross
|
|
69
|
+
the integration boundary. It does not install, bootstrap, restart, or stop the
|
|
70
|
+
daemon, change Codex configuration, perform login, manage a custom socket, or
|
|
71
|
+
accept remote WebSocket control. Projmux shutdown does not stop the shared
|
|
72
|
+
daemon. No existing Agent create/resume, hook, review, catalog, model, or usage
|
|
73
|
+
consumer uses the native source in this phase. Settings displays the decision
|
|
74
|
+
as a read-only state row; it is not a user-selectable authority.
|
|
75
|
+
|
|
42
76
|
### 3. UI orchestration
|
|
43
77
|
Picker data is modeled independently from row rendering. The app builds
|
|
44
78
|
backend-neutral `picker.Item` values (`Title`, `Value`, `SearchText`,
|
|
@@ -71,7 +105,7 @@ Responsibilities that remain outside `projmux`:
|
|
|
71
105
|
Config should be explicit and file-backed.
|
|
72
106
|
|
|
73
107
|
Candidate areas:
|
|
74
|
-
- managed roots
|
|
108
|
+
- managed roots (scan roots for candidate discovery; never managed identity)
|
|
75
109
|
- default home-like roots
|
|
76
110
|
- preview preferences
|
|
77
111
|
- session naming exceptions
|
|
@@ -80,7 +114,7 @@ Candidate areas:
|
|
|
80
114
|
## State model
|
|
81
115
|
|
|
82
116
|
Persistent state:
|
|
83
|
-
- pins
|
|
117
|
+
- pins (typed: managed Project uid, or unregistered candidate path)
|
|
84
118
|
- lightweight user preferences
|
|
85
119
|
|
|
86
120
|
Ephemeral runtime state:
|
|
@@ -113,30 +147,166 @@ Packages:
|
|
|
113
147
|
allocation, schema migration, snapshot reconciliation, and the operation
|
|
114
148
|
transaction. It performs no I/O; the clock, uid source, and root-directory
|
|
115
149
|
probe are injected through `Mutator`.
|
|
150
|
+
- `internal/core/resourcegraph` is pure: the resolved resource graph that joins
|
|
151
|
+
the Registry's desired topology to one exact tmux server, the typed
|
|
152
|
+
session/window/pane inventory it is resolved against, the closed attribution
|
|
153
|
+
and status vocabularies, and the transport descriptor. It performs no I/O.
|
|
154
|
+
- `internal/core/runtimediag` is pure: the read-only projection of one resolved
|
|
155
|
+
graph's runtime half -- every observed tmux object with its attribution, its
|
|
156
|
+
exact coordinate, and the Registry resource it is bound to, plus the scopes
|
|
157
|
+
that could not be observed. It performs no I/O and re-derives no attribution.
|
|
158
|
+
- `internal/core/registryview` is pure: the primary navigation view model. It
|
|
159
|
+
projects a resolved graph plus the caller's filesystem discovery onto the rows
|
|
160
|
+
the Projects, Sessions, and Recent Windows surfaces list -- Registry resources
|
|
161
|
+
in Registry order, discovered directories in their own section, and one Runtime
|
|
162
|
+
link -- with a status overlay and the actions each resource state is eligible
|
|
163
|
+
for. It performs no I/O.
|
|
164
|
+
- `internal/core/controller` is pure: the command-scoped controller kernel. It
|
|
165
|
+
owns the closed intent x attribution authority table, the guard evidence, and
|
|
166
|
+
the totally ordered plan every convergence producer is authorized through. It
|
|
167
|
+
performs no I/O and holds no tmux dependency; the guard field spellings are
|
|
168
|
+
supplied by the caller.
|
|
116
169
|
- `internal/integrations/metadata` owns the registry file (lock, atomic write,
|
|
117
|
-
migration)
|
|
170
|
+
migration), the tmux transport mirror, and the bounded observation adapter that
|
|
171
|
+
fills a `resourcegraph.Inventory` from one exact server.
|
|
118
172
|
- `internal/integrations/tmuxopts` is a dependency-free leaf holding the
|
|
119
173
|
canonical spelling of every projmux-owned tmux option name, so the generated
|
|
120
174
|
tmux config, session-state replay, and the resource mirror cannot drift.
|
|
121
175
|
|
|
122
176
|
Resources and ownership:
|
|
123
177
|
|
|
124
|
-
- Kinds are `Project`, `Window`, `Pane`, and `
|
|
125
|
-
`apiVersion: projmux.io/v1alpha1`.
|
|
126
|
-
- `ownerRef` runs Project → Window → (shell Pane | Agent),
|
|
127
|
-
its current managed Pane.
|
|
178
|
+
- Kinds are `Project`, `Window`, `Pane`, `Agent`, and `ControlSession`, stamped
|
|
179
|
+
with `apiVersion: projmux.io/v1alpha1`.
|
|
180
|
+
- `ownerRef` runs (Project | ControlSession) → Window → (shell Pane | Agent),
|
|
181
|
+
and an Agent owns its current managed Pane. A Window's allowed owner set is
|
|
182
|
+
exactly those two root kinds; every other ownerRef kind is refused.
|
|
183
|
+
- A `ControlSession` is the app-owned control session -- the Home session
|
|
184
|
+
`projmux shell` opens -- as a Registry root. It exists so Home's Windows and
|
|
185
|
+
Panes have an owner chain at all: before it, pane `%0` of Home carried no
|
|
186
|
+
`@projmux_window_uid`, so every route that resolves "the active target"
|
|
187
|
+
refused there.
|
|
188
|
+
- **A ControlSession owns no filesystem path, and `ControlSessionSpec` has no
|
|
189
|
+
field that could hold one.** `spec.session` names the exact tmux session and
|
|
190
|
+
nothing else. That is the structural guarantee behind "$HOME is never a
|
|
191
|
+
Project": managed roots, trust, rebind, cwd defaults, and `ProjectByRoot` all
|
|
192
|
+
read `Project.spec.root`, so a control session cannot leak into any of them
|
|
193
|
+
even by accident. `$HOME` is never registered as a Project and never added to
|
|
194
|
+
managed roots.
|
|
195
|
+
- A ControlSession is recognized as one only on evidence, never by name: the
|
|
196
|
+
server must carry `@projmux_app=1` and the exact session's
|
|
197
|
+
`@projmux_session_role` must be exactly `control`. `@projmux_ephemeral=1`
|
|
198
|
+
together with a control role fails closed on both sides -- the reader refuses
|
|
199
|
+
the pair and the writer refuses to produce it.
|
|
200
|
+
- Control identity is one declarative controller plan, not an install-time
|
|
201
|
+
migration. The canonical shell lifecycle declares one exact socket/session;
|
|
202
|
+
`config apply` declares the canonical Home target on the exact `-L` server it
|
|
203
|
+
just reloaded; and later lifecycle triggers may continue only an exact
|
|
204
|
+
ControlSession identity already stored in the Registry. For those inputs the
|
|
205
|
+
root, control role, and every Window/Pane uid owner-chain mirror converge from
|
|
206
|
+
any partial state, and a second pass performs no Registry or tmux write.
|
|
207
|
+
Foreign or duplicate claimants and any Project uid claim refuse the whole
|
|
208
|
+
plan before its first write. Session-name resemblance, cwd, commands,
|
|
209
|
+
display names, and the app marker by itself are never promotion evidence.
|
|
210
|
+
- ControlSession names share the registry-wide scope with Project names but not
|
|
211
|
+
a reservation slot, because `nameReservations` is keyed by kind as well as
|
|
212
|
+
scope. A Project named `home` and a ControlSession named `home` coexist.
|
|
128
213
|
- A persistent tmux **Session is not a resource**. It is a 1:1 runtime
|
|
129
214
|
projection of a Project recorded in `Project.status.session` with a `live`
|
|
130
215
|
flag, and it owns no uid, name, or ownerRef. Auto-attach ephemeral sessions
|
|
131
|
-
live only in runtime inventory, outside the Project hierarchy.
|
|
216
|
+
live only in runtime inventory, outside the Project hierarchy. A
|
|
217
|
+
ControlSession is not a counter-example: it is a root resource that *names* a
|
|
218
|
+
session, and the session still carries no identity of its own.
|
|
132
219
|
- `Window` and `Pane` carry **no stored liveness field**, deliberately. Their
|
|
133
220
|
`status` block holds observed conditions only; live/offline is derived from a
|
|
134
221
|
live tmux observation at read time. See *Runtime observation and resource
|
|
135
222
|
status* below.
|
|
136
|
-
- Every
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
223
|
+
- Every non-empty Project stores one exact canonical Window and every Window
|
|
224
|
+
stores a role-independent Pane anchor. `Project.spec.primaryWindowRef`
|
|
225
|
+
resolves to a Project-owned Window whenever the Project owns any Windows;
|
|
226
|
+
it is empty only for the valid closed zero-Window state. Final schema v2 requires
|
|
227
|
+
`Window.spec.anchorPaneRef`; it may resolve through the same Window ancestry
|
|
228
|
+
to either a direct `role=shell` Pane or an Agent-owned `role=agent` Pane.
|
|
229
|
+
`Window.spec.defaultShellPaneRef` is optional; when present it resolves only
|
|
230
|
+
to a directly Window-owned `role=shell` Pane. Project registration creates
|
|
231
|
+
both refs on the same initial shell **offline**, with no tmux involvement,
|
|
232
|
+
so Project and Window metadata stays queryable while tmux is down.
|
|
233
|
+
Phase-2 consumers resolve `anchorPaneRef` as the stable role-agnostic split
|
|
234
|
+
target. An explicit Pane selector or popup origin wins over that stored ref;
|
|
235
|
+
the stored anchor is consulted only when the invocation scopes no Pane.
|
|
236
|
+
Shell-required offline creation may adopt or lazily allocate the optional
|
|
237
|
+
default shell without replacing an Agent anchor. Consumers never write the
|
|
238
|
+
removed intermediate `primaryPaneRef` field.
|
|
239
|
+
Canonical deletion preserves the same invariant: deleting the primary Window
|
|
240
|
+
reanchors to the first existing valid sibling, while deleting the last Window
|
|
241
|
+
leaves the existing Project with an empty `primaryWindowRef`, a non-live
|
|
242
|
+
session observation, and no replacement allocation. The requested Window and
|
|
243
|
+
all descendants are removed exactly; only explicit Project deletion removes
|
|
244
|
+
the owning root itself.
|
|
245
|
+
|
|
246
|
+
Home and root kinds:
|
|
247
|
+
|
|
248
|
+
This is the one place that answers "what is Home". The word names three
|
|
249
|
+
different things and they are not interchangeable.
|
|
250
|
+
|
|
251
|
+
- **The Home tmux session is a `ControlSession` root, and it is not a Project.**
|
|
252
|
+
`projmux shell` opens it, the convergence pass marks it
|
|
253
|
+
`@projmux_session_role=control` on an `@projmux_app=1` server, and
|
|
254
|
+
`BindControlSession` records it as a Registry root that owns Windows and
|
|
255
|
+
Panes. It has **no path**: `ControlSessionSpec` holds `spec.session`, the
|
|
256
|
+
exact tmux session name, and has no field that could hold a root. `$HOME` is
|
|
257
|
+
therefore never a Project root, never a managed root, never a rebind target,
|
|
258
|
+
and never returned by `ProjectByRoot`, and no route registers it as one.
|
|
259
|
+
- **`$HOME` the directory is a discovery candidate like any other.** If
|
|
260
|
+
filesystem discovery offers it, it is an unregistered bootstrap candidate. It
|
|
261
|
+
becomes a Project only if an operator explicitly opens it, and doing so
|
|
262
|
+
creates an ordinary Project that has nothing to do with the ControlSession.
|
|
263
|
+
- **The sidebar Home chrome row is neither of the above.** It is a synthesized
|
|
264
|
+
navigation row for the operator's own root: it carries no uid, no
|
|
265
|
+
`resourceRef`, and no managed identity, it is not a reconcile or create
|
|
266
|
+
target, and it disappears entirely when discovery does not offer `$HOME`. It
|
|
267
|
+
leads the Projects list because it is where the surface starts from, not
|
|
268
|
+
because it is a member of what the surface orders. Home's *Windows and Panes*,
|
|
269
|
+
by contrast, are managed rows -- they are owned by the ControlSession root --
|
|
270
|
+
while the Home session row itself stays classified `control` with no
|
|
271
|
+
`resourceRef`. See *Registry-first primary navigation* below for the row-level detail.
|
|
272
|
+
|
|
273
|
+
The root kinds may retain the same preferred tmux session name in stored state,
|
|
274
|
+
but that does not make the name an identity edge. An exact
|
|
275
|
+
`ControlSession.spec.session` claim wins before any Project session-name
|
|
276
|
+
fallback. If an explicitly opened Project would otherwise project onto that
|
|
277
|
+
physical session, its stable runtime name is `<preferred>--<full Project uid>`;
|
|
278
|
+
the Registry Project uid, root, and owner chain remain unchanged. A Project
|
|
279
|
+
uid/root observed on the exact control-owned session is D4 contamination, not
|
|
280
|
+
permission to adopt or rewrite the control-owned descendants. Observation
|
|
281
|
+
failures are quarantined as reason-bearing D6 items so an unrelated session can
|
|
282
|
+
still reconcile; only exact socket evidence authorizes runtime writes.
|
|
283
|
+
|
|
284
|
+
The consequence for every consumer is one rule: **the Registry has two root
|
|
285
|
+
kinds and a projection that walks roots has to walk both.** A traversal that
|
|
286
|
+
reads `registry.Projects` as if it were the whole root set will drop, refuse,
|
|
287
|
+
or fail to report whatever a ControlSession owns. Kind-scoped reads are still
|
|
288
|
+
fine and are the common case -- a Project root path, a Project session claim, a
|
|
289
|
+
Project pin -- but they are scoped on purpose, not by omission. The classified
|
|
290
|
+
list of every root traversal in the tree, and which of the two kinds each one
|
|
291
|
+
handles, is maintained as an executable table in
|
|
292
|
+
`internal/app/resource_reconcile_root_kind_test.go`; it is re-derived from the
|
|
293
|
+
source on every test run, so a traversal added or moved fails until it is
|
|
294
|
+
classified.
|
|
295
|
+
|
|
296
|
+
Divergence taxonomy:
|
|
297
|
+
|
|
298
|
+
Reconciliation uses one closed, additive classification for every plan and
|
|
299
|
+
refusal item. The item's human-readable `reason` remains separate from its
|
|
300
|
+
machine-readable `divergence` label: `D1-unrealized` is declared Registry state
|
|
301
|
+
with no realization, `D2-unattributed` is observed state with no exact resource
|
|
302
|
+
attribution, `D3-orphan-mirror` is a runtime mirror whose uid is absent from the
|
|
303
|
+
Registry, `D4-contamination` is a conflicting Registry identity or contradictory
|
|
304
|
+
exact evidence, `D5-drift` is a bound resource whose declared and observed
|
|
305
|
+
fields differ, and `D6-unknown` is the fail-closed remainder. This taxonomy does
|
|
306
|
+
not replace the resolver's managed/control/ephemeral/recoverable/unattributed/
|
|
307
|
+
foreign/conflict runtime classes or the reconciler's missing/stale/foreign drift
|
|
308
|
+
vocabulary. Doctor and dry-run reports expose counts for all six labels;
|
|
309
|
+
support reports expose those counts but redact item reasons and identifiers.
|
|
140
310
|
|
|
141
311
|
Identity and naming:
|
|
142
312
|
|
|
@@ -181,10 +351,186 @@ Root lifecycle:
|
|
|
181
351
|
|
|
182
352
|
Agent lifecycle:
|
|
183
353
|
|
|
184
|
-
- The phase set is exactly `Pending`, `Running`, `Offline`, `Failed`.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
354
|
+
- The phase set is exactly `Pending`, `Running`, `Offline`, `Failed`. An
|
|
355
|
+
abnormal exit resolves to `Failed`, while killed or unexplained disappearance
|
|
356
|
+
resolves to `Offline` and retains the Agent/Pane rows for diagnosis and
|
|
357
|
+
explicit recovery. A same-generation supervisor exit 0 paired with exact
|
|
358
|
+
`pane-exited` evidence is different: a non-last Pane is removed while its
|
|
359
|
+
Agent is retained Offline. For a last Pane, the complete Window subtree stays
|
|
360
|
+
pending until the exact causal `window-unlinked` half arrives.
|
|
361
|
+
- `Offline` for an unexplained disappearance rather than `Failed` is a deliberate
|
|
362
|
+
asymmetry. The phase is what an operator reads to decide whether to resume, and
|
|
363
|
+
an unproven `Failed` is worse for that decision than an honest `Offline`; the
|
|
364
|
+
fact that the answer is unproven is carried by `status.lastTermination`, where
|
|
365
|
+
it can be read without being mistaken for a diagnosis.
|
|
366
|
+
|
|
367
|
+
Termination evidence transport:
|
|
368
|
+
|
|
369
|
+
- A managed Pane's `status.activation` names one **materialization** of that
|
|
370
|
+
Pane, not the Pane. It carries an opaque `generation` minted per launch,
|
|
371
|
+
resume, and topology materialization, the exact `%N` handle it landed on, the
|
|
372
|
+
owning Agent uid for an Agent-managed Pane, and the operation id that issued
|
|
373
|
+
it. The uid survives kill/recreate and resume; the generation does not, and
|
|
374
|
+
that is what lets a receipt from a replaced process be recognized as stale
|
|
375
|
+
instead of applied to the Pane that now holds the uid.
|
|
376
|
+
- Every managed launch execs `projmux internal supervise --pane-uid <uid>
|
|
377
|
+
--generation <gen> [--agent-uid <uid>] -- <command>...`. The supervisor gives
|
|
378
|
+
the child this pane's exact stdin/stdout/stderr -- the pty tmux allocated, not
|
|
379
|
+
a pipe -- puts it in its own process group, and makes that group the
|
|
380
|
+
terminal's foreground group, so job control works and
|
|
381
|
+
`#{pane_current_command}` keeps naming the child. argv, cwd, and the
|
|
382
|
+
inherited environment are untouched except for two private `PMX_INTERNAL_*`
|
|
383
|
+
capability values carrying this Pane uid and activation generation to the
|
|
384
|
+
provider's own hook children. They are not public `PROJMUX_*` hook API and
|
|
385
|
+
are accepted only together with the Registry binding and exact recorded `%N`
|
|
386
|
+
runtime handle. tmux-side signals aimed at the pane process are relayed to
|
|
387
|
+
the child's group, because the pane pid and the child pid used to be the same
|
|
388
|
+
process. The foreground handoff is attempted and retried without
|
|
389
|
+
it rather than probed for: there is no portable way to ask "is fd 0 my
|
|
390
|
+
controlling terminal" without an ioctl, and a start that fails forks no
|
|
391
|
+
surviving child.
|
|
392
|
+
- A managed shell Pane -- one created with no command of its own -- is
|
|
393
|
+
supervised over the process tmux itself would have started: `default-command`
|
|
394
|
+
run by `default-shell` when it is set, and a **login** shell (argv[0] prefixed
|
|
395
|
+
with `-`) when it is empty. Both values are read from the same exact server
|
|
396
|
+
the pane is created on.
|
|
397
|
+
- `status.lastTermination` on the Pane, mirrored onto the owning Agent, is the
|
|
398
|
+
minimal durable receipt: closed `source` and `classification` vocabularies,
|
|
399
|
+
`observedAt`, the Pane uid, the optional Agent uid, the generation, and either
|
|
400
|
+
an exit code or a signal name, plus the operation id. It carries no command
|
|
401
|
+
text, no pane content, and no provider conversation data. Like
|
|
402
|
+
`status.sessionRef` it is an optional pointer with `omitempty` and additive
|
|
403
|
+
inside `schemaVersion: 1`.
|
|
404
|
+
- The classification vocabulary is four **kinds of proof**, not a severity
|
|
405
|
+
ladder. `intentional` is a canonical control action's own written record and
|
|
406
|
+
may only come from `source: control-action`. `normal` and `abnormal` mean a
|
|
407
|
+
supervisor actually reaped the child: exit 0 and everything else,
|
|
408
|
+
respectively. `unknown` is an explicitly evidence-free record. **Exit 0 is
|
|
409
|
+
never promoted to intent**: a provider that exits because the operator quit
|
|
410
|
+
and one that exits because it finished a batch produce byte-identical wait
|
|
411
|
+
statuses.
|
|
412
|
+
- Receipts are applied under a generation guard: the Pane must still exist, the
|
|
413
|
+
receipt's generation must be the Pane's current one, a receipt naming an Agent
|
|
414
|
+
must name the Agent that owns the Pane and still binds it, a receipt the
|
|
415
|
+
registry already stores verbatim is a no-op, and recorded intent is sticky for
|
|
416
|
+
its generation. The last rule is load bearing -- a canonical delete records
|
|
417
|
+
intent and then kills the pane, and the supervisor watching it reports the
|
|
418
|
+
resulting signal; letting the observation win would turn every deliberate
|
|
419
|
+
deletion into a crash report.
|
|
420
|
+
- Canonical `delete window|pane|agent` commits its intentional receipt in **its
|
|
421
|
+
own transaction, before the first live mutation**. A failure to make that
|
|
422
|
+
evidence durable aborts with zero tmux mutations. Every refusal after it
|
|
423
|
+
withdraws the receipt again, scoped by the operation id so it can only remove
|
|
424
|
+
what it wrote; a partial delete that really did kill something keeps the
|
|
425
|
+
evidence that explains it.
|
|
426
|
+
- Exit reconciliation is what consumes a receipt; see below.
|
|
427
|
+
- The lock-free `termination-receipts.jsonl` row precedes a clean process exit
|
|
428
|
+
and therefore outlives a qualifying Pane/Agent Registry deletion. That bounded
|
|
429
|
+
receipt is the post-delete diagnostic: source, classification, observed time,
|
|
430
|
+
Pane/Agent uid, generation, and wait status only. No command, pane content,
|
|
431
|
+
prompt, transcript, or provider payload is recorded.
|
|
432
|
+
- The supervisor resolves its state paths from the pane's own inherited
|
|
433
|
+
environment, which is the tmux **server's** environment rather than the
|
|
434
|
+
environment of the CLI call that created the pane. That is the correct
|
|
435
|
+
production binding -- the server is started from the operator's session -- and
|
|
436
|
+
it is why an isolated test has to start its server with the same state root it
|
|
437
|
+
reads the receipts back from.
|
|
438
|
+
- Losing a receipt is a supported outcome, not a failure mode. A supervisor
|
|
439
|
+
killed with `SIGKILL`, a lost tmux server, an unwritable registry, and a pane
|
|
440
|
+
whose supervisor could not be constructed all leave no receipt, and the pane
|
|
441
|
+
behaves exactly as it did before supervision existed. An absent receipt is the
|
|
442
|
+
input that resolves to `unknown`; it is never read as a normal exit.
|
|
443
|
+
- A managed process that dies before the create transaction that launched it
|
|
444
|
+
commits is a real edge exit reconciliation owns: the reconciliation runs inside
|
|
445
|
+
the next mutation's transaction and can retire the Pane before the supervisor's
|
|
446
|
+
receipt arrives. The receipt is then refused as stale, which is the correct
|
|
447
|
+
outcome -- the Pane it describes is gone -- and the Agent converges on
|
|
448
|
+
`Offline` with `unknown` evidence rather than on invented evidence.
|
|
449
|
+
- A Pane **adopted** from a runtime object created for another reason -- the
|
|
450
|
+
first pane a `new-session` brings with it -- carries no generation until it is
|
|
451
|
+
relaunched. Adoption is not supervision: the process was already running, so
|
|
452
|
+
there is nothing to have launched it with.
|
|
453
|
+
|
|
454
|
+
Exit reconciliation and lifecycle projection:
|
|
455
|
+
|
|
456
|
+
- A **lifecycle dirty event** is one exact-host statement that a managed runtime
|
|
457
|
+
object's lifecycle may have changed. `pane-exited` carries tmux's exact
|
|
458
|
+
`#{hook_pane}`. Its current-context session/window formats may already name a
|
|
459
|
+
survivor, so the owner `$N/@N` comes from the Window's last live Registry
|
|
460
|
+
observation; `window-unlinked` carries exact `#{hook_session}` and
|
|
461
|
+
`#{hook_window}` for the dead Window. `after-kill-pane` carries neither because
|
|
462
|
+
tmux leaves `#{hook_pane}` empty there. Whole-host and coalesced events remain
|
|
463
|
+
advisory projection inputs and never acquire delete authority.
|
|
464
|
+
- The event is advisory. The reconciliation re-observes the **final** snapshot of
|
|
465
|
+
that same exact host and re-reads the registry, so a stale event, a duplicate
|
|
466
|
+
event, and an event for a pane that has since come back all converge on the
|
|
467
|
+
same state as no event at all.
|
|
468
|
+
- The observation is the same mirrored-uid read (`list-panes -a -F
|
|
469
|
+
'#{@projmux_pane_uid}...'`) the reconciler and the active-target fallback
|
|
470
|
+
already share, routed through the event's exact target: an explicit `-L/-S`
|
|
471
|
+
addresses that server only, and a zero target routes through the inherited
|
|
472
|
+
client, which is the absolute socket in `$TMUX`. There is no default-socket
|
|
473
|
+
fallback -- a reconciliation that summed two servers could never report a death
|
|
474
|
+
at all, and a sibling server carrying the same `%N` handles or the same
|
|
475
|
+
mirrored uid receives zero calls.
|
|
476
|
+
- The retained-state transition is derived from the receipt the Pane already
|
|
477
|
+
stores. `abnormal` lands the Agent in `Failed`; `killed` and an evidence-free
|
|
478
|
+
disappearance land it in `Offline`. A `normal` receipt alone is still only
|
|
479
|
+
evidence. It becomes Pane/Agent delete authority only when the same controller
|
|
480
|
+
pass also has the exact hook Pane and Window, a non-empty exact-socket
|
|
481
|
+
inventory, and a current generation/owner chain.
|
|
482
|
+
- An absence with no receipt **records** an `unknown` one, with
|
|
483
|
+
`source: reconcile`. That is what makes the reconciliation idempotent: a second
|
|
484
|
+
pass finds the same document already stored, recording it is a no-op, and the
|
|
485
|
+
registry is left byte-identical. An absence with no stored value would be
|
|
486
|
+
re-projected on every pane exit in every session, forever.
|
|
487
|
+
- `source: reconcile` and `classification: unknown` may only appear together.
|
|
488
|
+
Unknown is a statement that nothing was observed, and letting a supervisor that
|
|
489
|
+
read a wait status or a control action that stated its intent file one would let
|
|
490
|
+
either of them erase evidence with it.
|
|
491
|
+
- A qualifying exact clean non-last exit removes only the Pane and leaves its
|
|
492
|
+
Agent Offline in the same locked Registry transaction. The owning Window,
|
|
493
|
+
Project/ControlSession, sibling Panes and sibling Agents are unchanged. A
|
|
494
|
+
shell and a provider are intentionally indistinguishable here: both are
|
|
495
|
+
supervised wait-status 0; no command or `/exit` text participates.
|
|
496
|
+
- Abnormal, killed, unknown, whole-host absence, missing/empty server inventory,
|
|
497
|
+
permission failure, foreign Window observation, stale generation, and an
|
|
498
|
+
Agent that now binds a resumed Pane all produce delete-plan zero. They keep the
|
|
499
|
+
retained lifecycle projection and canonical explicit Offline delete recovery.
|
|
500
|
+
- The closed Agent transition table stays the authority. An Agent that may not
|
|
501
|
+
reach the implied phase keeps its phase, its `paneRef`, and its managed Pane;
|
|
502
|
+
only the evidence is recorded. A refused transition is not a reason to discard
|
|
503
|
+
what was observed.
|
|
504
|
+
- Cost is measured in transactions. The projection set is computed against a
|
|
505
|
+
read-only snapshot and the write lock is taken only when something is
|
|
506
|
+
outstanding -- unrecorded evidence, or an Agent still bound to a dead Pane that
|
|
507
|
+
can still move -- so a reconciled disappearance costs zero transactions on every
|
|
508
|
+
later pass. Inside the lock the host is re-observed and the set recomputed,
|
|
509
|
+
because the registry may have gained a freshly created Agent while the event
|
|
510
|
+
waited, and applying the pre-lock observation to that newer registry would
|
|
511
|
+
release the new Agent and delete its still-live Pane.
|
|
512
|
+
- It fails closed. An observation that could not be taken is indistinguishable
|
|
513
|
+
from one that found nothing, and reading it as empty would file an `unknown`
|
|
514
|
+
termination against every managed Pane on a machine whose tmux server simply is
|
|
515
|
+
not up. It is also not an error: the reconciliation rides along inside other
|
|
516
|
+
operations and must never fail them.
|
|
517
|
+
- Ordering: a supervisor writes its receipt before its own process exits, so the
|
|
518
|
+
journal evidence is durable before tmux tears the pane down. The controller
|
|
519
|
+
absorbs it, re-resolves the exact `%N`/`@N` owner on the event socket, and then
|
|
520
|
+
repeats both inventory and owner/generation checks under the Registry lock.
|
|
521
|
+
Duplicate and permuted receipt delivery is idempotent. A late old-generation
|
|
522
|
+
receipt or a resume that wins the lock cannot follow the new binding.
|
|
523
|
+
- The reconciliation performs no runtime call beyond that one observation. It
|
|
524
|
+
never resumes an Agent, starts an offline resource, materializes a replacement
|
|
525
|
+
Pane, deletes an unmanaged object, or adopts one. An observation is not an
|
|
526
|
+
activation authority.
|
|
527
|
+
- `get pane|agent` renders the stored receipt in a `TERMINATION` column --
|
|
528
|
+
`<classification>/<source>` with the exit status when one was read, plus a
|
|
529
|
+
relative age -- and `describe pane|agent` renders the classification, source,
|
|
530
|
+
observed instant, exit code or signal, Pane ref, generation, and operation id as
|
|
531
|
+
their own rows. The Registry-first navigation carries the same receipt on its
|
|
532
|
+
Pane and Agent rows. All three are pure projections: a read verb never consumes
|
|
533
|
+
a receipt, advances a phase, or writes to the registry.
|
|
188
534
|
|
|
189
535
|
Agent provider session ref:
|
|
190
536
|
|
|
@@ -237,11 +583,39 @@ Agent provider session ref:
|
|
|
237
583
|
hook whose provider contradicts the Agent's `spec.provider` is refused with
|
|
238
584
|
zero mutations.
|
|
239
585
|
|
|
586
|
+
Agent launch argv (workspace / task boundary):
|
|
587
|
+
|
|
588
|
+
- One Agent launch hands the provider CLI two independent things in a single
|
|
589
|
+
argv: the **workspace** (`--cwd` and every `--add-dir` the create validated)
|
|
590
|
+
and the **initial task payload** given after `--`. Where the workspace stops
|
|
591
|
+
is a property of the provider's own parser, so the boundary is provider
|
|
592
|
+
grammar data in `internal/app/agent_launch_argv.go`, not a concatenation at
|
|
593
|
+
the call site. `create agent` and `agent resume` read that one grammar, so a
|
|
594
|
+
provider's option arity cannot be spelled two ways.
|
|
595
|
+
- Claude's `--add-dir <directories...>` is **variadic**: it consumes every
|
|
596
|
+
following operand until an option-looking token or `--` stops it. So every
|
|
597
|
+
root travels in one occurrence and the payload is introduced by `--`. A
|
|
598
|
+
payload appended straight after the roots is parsed as one more directory, and
|
|
599
|
+
the session then starts with no task at all — an installed regression that is
|
|
600
|
+
invisible in the argv and surfaces only as an unacknowledged activation.
|
|
601
|
+
- Codex's `-C <DIR>` and `--add-dir <DIR>` each take exactly one value, so roots
|
|
602
|
+
repeat the option and no payload can be absorbed. Codex's argv is deliberately
|
|
603
|
+
left byte-identical, which is also why a Codex prompt beginning with `-` is
|
|
604
|
+
still read in option position, exactly as before.
|
|
605
|
+
- projmux gives additional roots only to Codex and Claude. A stored root for any
|
|
606
|
+
other provider is refused at launch construction rather than translated into a
|
|
607
|
+
flag this seam never validated, so an Agent never starts with access narrower
|
|
608
|
+
than what it records.
|
|
609
|
+
- An empty payload contributes nothing, so the interactive create and the resume
|
|
610
|
+
argv (where the provider's own conversation option, not a terminator, ends the
|
|
611
|
+
variadic root option) are unchanged.
|
|
612
|
+
|
|
240
613
|
Agent resume:
|
|
241
614
|
|
|
242
615
|
- `agent resume <ref>` rebinds an existing Agent: it builds the provider's
|
|
243
|
-
**resume** argv from `status.sessionRef`,
|
|
244
|
-
|
|
616
|
+
**resume** argv from `status.sessionRef`, resolves the target Window's
|
|
617
|
+
exact role-agnostic `anchorPaneRef`, splits a new managed Pane detached, and
|
|
618
|
+
attaches it to that Agent. The
|
|
245
619
|
`metadata.uid` and `metadata.name` do not change, `status.phase` becomes
|
|
246
620
|
`Running`, and `status.paneRef` points at the new Pane. `status.sessionRef`
|
|
247
621
|
itself is read and never rewritten by resume.
|
|
@@ -278,11 +652,12 @@ Agent resume:
|
|
|
278
652
|
Registry file and schema:
|
|
279
653
|
|
|
280
654
|
- The registry lives at `<state>/projmux/metadata/registry.json` (0600 below a
|
|
281
|
-
0700 directory)
|
|
282
|
-
stale-lock breaking, matching the notify queue and
|
|
283
|
-
-
|
|
284
|
-
|
|
285
|
-
|
|
655
|
+
0700 directory). Ordinary mutations use an `O_CREATE|O_EXCL` lock file with
|
|
656
|
+
bounded retry and stale-lock breaking, matching the notify queue and
|
|
657
|
+
recent-windows stores. Explicit Registry repair uses its own recovery lock;
|
|
658
|
+
see the recovery boundary below.
|
|
659
|
+
- The envelope carries `schemaVersion: 2`. Version 1 is the first Registry
|
|
660
|
+
envelope projmux wrote and is the only older version this build migrates.
|
|
286
661
|
- Everything else fails closed: the file is refused as unreadable and **no
|
|
287
662
|
write happens at all** — no rewrite, no backup, no staged temp file. This
|
|
288
663
|
covers a **newer** schemaVersion (which would destroy state a newer build
|
|
@@ -292,24 +667,269 @@ Registry file and schema:
|
|
|
292
667
|
at the registry path, which is exactly the write-on-unknown-input that
|
|
293
668
|
fail-closed exists to prevent. The registry is deliberately not quarantined
|
|
294
669
|
or reset the way a corrupt recent-windows file is.
|
|
295
|
-
- A file that is absent, empty, or whitespace-only is
|
|
296
|
-
|
|
297
|
-
actual content and no usable `schemaVersion`
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
670
|
+
- A file that is absent, empty, or whitespace-only is the legitimate "no
|
|
671
|
+
registry yet" case **only before the first successful write**; see the durable
|
|
672
|
+
envelope below. Only a file with actual content and no usable `schemaVersion`
|
|
673
|
+
is refused as unknown.
|
|
674
|
+
- A normal locked `Load` of v1 runs the production 1 → 2 migration, validates
|
|
675
|
+
the repaired graph, writes the versioned backup, and publishes the v2 bytes
|
|
676
|
+
through the existing temp-file atomic replace. A failed repair leaves the v1
|
|
677
|
+
source bytes unchanged. Every successful first migrator (`Load`, `Update`,
|
|
678
|
+
`UpdateConvergent`, or explicit `Migrate`) also atomically publishes a 0600
|
|
679
|
+
`<exact-backup>.migration-report.json` beside the versioned backup before the
|
|
680
|
+
Registry replace. That durable evidence records the exact absolute backup
|
|
681
|
+
path, SHA-256 of its byte-identical v1 contents, version pair, repair/loss
|
|
682
|
+
counts, and every repair detail. It is outside rolling recovery retention.
|
|
683
|
+
A failed migration removes any staged/published report before returning while
|
|
684
|
+
leaving the source bytes unchanged. `LoadWithMigrationResult` and `Migrate`
|
|
685
|
+
additionally return both exact paths from the same locked transaction. A
|
|
686
|
+
second pass sees v2 and writes neither Registry, backup, nor report bytes. An
|
|
687
|
+
existing invalid v2 document is validated and refused byte-identically even
|
|
688
|
+
when explicit `Migrate` has no version step to run.
|
|
689
|
+
Explicit read-only inspection migrates only its returned in-memory view and
|
|
690
|
+
never publishes it.
|
|
691
|
+
- The v1 repair is deterministic over Registry order apart from injected opaque
|
|
692
|
+
uid generation. It preserves every existing uid, ownerRef, reserved name, and
|
|
693
|
+
Agent conversation/session pointer. A valid Project anchor is never
|
|
694
|
+
reselected. Otherwise it selects the first valid Project-owned Window and
|
|
695
|
+
direct shell-Pane chain; a Window with no valid direct shell promotes its
|
|
696
|
+
first direct shell or receives one bare shell, and a Project with no Window
|
|
697
|
+
receives the minimum Window/Pane chain. A non-empty shell cwd that no longer
|
|
698
|
+
names a directory is downgraded to the Project root. Every repair is recorded
|
|
699
|
+
in `MigrationReport`; replaced declared fields are separately marked as
|
|
700
|
+
information loss, while created Window/Pane resources are additive repair.
|
|
701
|
+
Production and golden tests call the same pure repair algorithm with injected
|
|
702
|
+
directory-existence and uid adapters.
|
|
703
|
+
- The canonical anchor is a schema-v2 write invariant. Until the separately
|
|
704
|
+
planned Project-start projection lands, the legacy `New` startup path's
|
|
705
|
+
prune-to-zero transaction fails validation and commits zero Registry bytes.
|
|
706
|
+
The Registry verdict precedes snapshot deletion, so that rejection also
|
|
707
|
+
preserves the latest snapshot byte-for-byte and performs no tmux mutation.
|
|
708
|
+
This fail-closed ordering is not Phase 3 authority to redesign Project start.
|
|
709
|
+
- Downgrade writes remain unsupported. Unversioned, malformed, and future
|
|
710
|
+
envelopes still fail closed before backup, staging, or replace.
|
|
711
|
+
- **Final schema-v2 Window shape:** the first public v2 contract has required
|
|
712
|
+
`anchorPaneRef` and optional `defaultShellPaneRef`. Unpublished v2 files with
|
|
713
|
+
`primaryPaneRef` are normalized under the Registry lock after an exact backup
|
|
714
|
+
and durable checksum report. A v1 file migrates directly to this final shape.
|
|
715
|
+
Mixed legacy/final authority is refused; the final writer emits no
|
|
716
|
+
`primaryPaneRef`; and a second final-v2 pass writes zero bytes.
|
|
306
717
|
- **Field spelling:** the registry file intentionally uses the resource-model
|
|
307
718
|
camelCase spelling (`apiVersion`, `schemaVersion`, `metadata`, `displayName`,
|
|
308
|
-
`ownerRef`, `
|
|
719
|
+
`ownerRef`, `anchorPaneRef`, `defaultShellPaneRef`, `spec`, `status`) rather than the snake_case
|
|
309
720
|
used by the older projmux on-disk JSON. The two spellings coexist on purpose:
|
|
310
721
|
existing snake_case files are **not** retro-changed, and the resource registry
|
|
311
722
|
follows the resource-model contract.
|
|
312
723
|
|
|
724
|
+
Durable recovery envelope:
|
|
725
|
+
|
|
726
|
+
- The registry is the source of truth for managed identity and desired topology,
|
|
727
|
+
so `registry.json` is not the whole state: beside it the store keeps
|
|
728
|
+
`registry.initialized`, the marker that records a completed write, and
|
|
729
|
+
`recovery/`, a bounded set of the bytes replaced by semantic writes. The marker
|
|
730
|
+
and every copy are 0600, `recovery/` is 0700 like the directory above it, and
|
|
731
|
+
**no read creates any of them**.
|
|
732
|
+
- **First use versus state loss.** Before the first successful write there is no
|
|
733
|
+
marker, and an absent, empty, or whitespace-only registry is the empty
|
|
734
|
+
first-use registry — the zero-write read contract is unchanged, including the
|
|
735
|
+
`LoadReadOnly` short-circuit that must not materialize
|
|
736
|
+
`<state>/projmux/metadata/` for an operator who has never registered a
|
|
737
|
+
resource. Once the marker exists, the same content-free registry is
|
|
738
|
+
`ErrRegistryStateLost` on ordinary loads and mutations. Recovery inspection
|
|
739
|
+
still classifies the missing or empty state and stays available to the repair
|
|
740
|
+
route. Answering an empty registry there would hide the loss of every uid,
|
|
741
|
+
name reservation, and offline resource, and the next mutation would mint a
|
|
742
|
+
second identity domain on top of it. A registry written before the marker
|
|
743
|
+
existed is ordinary state, not a loss, and gains the marker on its next write.
|
|
744
|
+
- **Rolling recovery copies.** A same-version semantic write copies the bytes it
|
|
745
|
+
is about to replace to `recovery/registry-<stamp>-<seq>.json` before the
|
|
746
|
+
replace. Only *verified* bytes are copied: an absent, empty, or
|
|
747
|
+
structurally invalid prior file yields no copy. An invalid Registry blocks
|
|
748
|
+
every ordinary write; only the separately validated, explicitly sourced
|
|
749
|
+
recovery route may replace it. Retention keeps the newest five and removes the
|
|
750
|
+
rest deterministically — names sort chronologically, and the sequence
|
|
751
|
+
continues past the newest name rather than reusing one retention freed. A
|
|
752
|
+
migration keeps its own versioned `.bak` instead, so it never spends a
|
|
753
|
+
recovery slot on bytes that already have a backup.
|
|
754
|
+
- **A convergent no-op writes nothing at all.** `UpdateConvergent` on an
|
|
755
|
+
unchanged registry takes no recovery copy, publishes no marker, and leaves the
|
|
756
|
+
registry's bytes, mtime, and inode untouched. Convergence agreeing with stored
|
|
757
|
+
state is not a reason to replace it.
|
|
758
|
+
- **Write sequence.** Stage into a temp file in the same directory → `fsync` it →
|
|
759
|
+
re-read and validate it → copy the prior verified bytes → publish the marker if
|
|
760
|
+
absent → `fsync` the directory → atomic `rename` → `fsync` the directory again.
|
|
761
|
+
The live registry is only ever touched by that rename, and every step before it
|
|
762
|
+
is undone on failure, so an injected or real failure at any step leaves the
|
|
763
|
+
prior registry byte-identical with no staged file, no orphan copy, and no
|
|
764
|
+
half-created marker. Directory `fsync` is best effort for filesystems that
|
|
765
|
+
reject it (DrvFs and friends, the same ones that reject the permission repair),
|
|
766
|
+
because losing the ability to write state there would be worse than losing the
|
|
767
|
+
ordering guarantee.
|
|
768
|
+
- The marker is published **before** the rename so that its own failure cannot
|
|
769
|
+
leave a replaced registry behind. The cost is a crash window of one rename: a
|
|
770
|
+
hard crash between the marker and the very first registry rename leaves a
|
|
771
|
+
marker with no registry, which reads as state loss rather than first use. That
|
|
772
|
+
direction is deliberate — it asks the operator instead of silently starting
|
|
773
|
+
over — and the diagnostic names the marker so it can be removed to accept an
|
|
774
|
+
empty registry.
|
|
775
|
+
- **Distinct diagnostics.** Missing-after-initialization
|
|
776
|
+
(`ErrRegistryStateLost`), malformed (`ErrMalformedRegistry`), too new
|
|
777
|
+
(`ErrSchemaTooNew`), and unreadable (`ErrRegistryPermission`) stay four
|
|
778
|
+
separate causes classified with `errors.Is`, because they ask for four
|
|
779
|
+
different repairs. None of them creates an empty registry or a uid.
|
|
780
|
+
- **Restore is a separate operation.** Producing and bounding the copies is a
|
|
781
|
+
property of a write; selecting one and putting it back is an operator decision,
|
|
782
|
+
so it lives in the recovery boundary below rather than in the write path.
|
|
783
|
+
|
|
784
|
+
Degraded Registry mode:
|
|
785
|
+
|
|
786
|
+
- `valid` and a legitimate `first-use` are the only states from which an
|
|
787
|
+
ordinary mutation may begin. Missing or empty state after initialization,
|
|
788
|
+
malformed JSON, unsupported/newer schema, an invalid resource graph, and an
|
|
789
|
+
unreadable Registry enter degraded mode. This is a command-scoped
|
|
790
|
+
classification, not a sticky process flag: a successful repair makes the next
|
|
791
|
+
mutation healthy again.
|
|
792
|
+
- Ordinary writes classify before entering the normal mutation lock and repeat
|
|
793
|
+
the graph guard after acquiring it. A degraded refusal wraps the existing
|
|
794
|
+
typed cause, states that ordinary mutations are disabled, and ends with the
|
|
795
|
+
exact no-write next command: `projmux reconcile registry --dry-run`. It never
|
|
796
|
+
leaves a raw validation error as the whole diagnosis and never chooses a
|
|
797
|
+
recovery source for the operator.
|
|
798
|
+
- Public resource reads explicitly opt into the degraded decode path, so a
|
|
799
|
+
decodable invalid graph remains available without weakening ordinary
|
|
800
|
+
low-level Store loads. Recovery inspection can
|
|
801
|
+
diagnose even malformed, missing, empty, unsupported, or graph-invalid bytes.
|
|
802
|
+
`projmux reconcile registry` is the only write allowed to replace degraded
|
|
803
|
+
Registry bytes. Other reconcile, create, rename, rebind, delete, lifecycle,
|
|
804
|
+
and convergence writes stay on the ordinary gate.
|
|
805
|
+
|
|
806
|
+
Registry recovery boundary (`projmux reconcile registry`):
|
|
807
|
+
|
|
808
|
+
- **Two operations with deliberately different powers.** Planning classifies the
|
|
809
|
+
current registry and every bounded candidate and writes nothing at all — no
|
|
810
|
+
lock, no permission repair, no directory creation, no tmux mutation. Restoring
|
|
811
|
+
publishes exactly one source the operator named. There is no "just fix it"
|
|
812
|
+
mode: which copy is the truth is a judgment about which mutations were wanted,
|
|
813
|
+
and the command never makes it.
|
|
814
|
+
- **A separate repair transaction.** Restore serializes on
|
|
815
|
+
`registry.json.repair.lock`, never acquires or waits for the ordinary
|
|
816
|
+
`registry.json.lock`, and therefore remains available when a failed or stale
|
|
817
|
+
ordinary writer holds that lock. This is not a validation bypass: the operator
|
|
818
|
+
must name one source; the source is classified and graph-validated before and
|
|
819
|
+
under the recovery lock; the staged bytes are classified and graph-validated
|
|
820
|
+
again; and source/current checksums are rechecked immediately before publish.
|
|
821
|
+
- **Classification, not authority.** A plan reports the current registry and each
|
|
822
|
+
candidate as `valid`, `first-use`, `missing`, `empty`, `malformed`,
|
|
823
|
+
`schema-too-new`, `invalid`, or `unreadable`, with a `sha256:` digest of the
|
|
824
|
+
exact bytes, size, mtime, and the resource/reservation counts a verified
|
|
825
|
+
envelope holds. Only `valid` is restorable. The same classifier runs at publish
|
|
826
|
+
time, so a source is never previewed one way and validated another.
|
|
827
|
+
- **Fail-closed on the source.** Malformed JSON, an empty file, an envelope newer
|
|
828
|
+
than this build, and a graph that decodes but holds a duplicate uid, a dangling
|
|
829
|
+
`ownerRef`, or a broken name reservation are all refused. Restoring an
|
|
830
|
+
unverified source would replace a known-damaged registry with an
|
|
831
|
+
unknown-damaged one, and the second state is worse because it looks healthy.
|
|
832
|
+
- **Byte-semantic restore.** The verified bytes are published verbatim rather than
|
|
833
|
+
re-encoded, so uids, owner relations, and name reservations are preserved
|
|
834
|
+
exactly, a repeat restore is a byte comparison instead of a normalization
|
|
835
|
+
argument, and an older-but-known schema stays readable through the existing safe
|
|
836
|
+
read and migrates on the next semantic write.
|
|
837
|
+
- **The bytes being replaced are kept.** A restore copies the current registry to
|
|
838
|
+
`recovery/replaced-<stamp>-<seq>.json` before replacing it, and unlike the
|
|
839
|
+
write-side copy it keeps content that does **not** verify — that damaged
|
|
840
|
+
registry is the only remaining evidence if the restore turns out to be the wrong
|
|
841
|
+
call. Replaced copies are their own bounded family, so a restore never consumes
|
|
842
|
+
the automatic write history and never grows without bound.
|
|
843
|
+
- **Race guards.** `--expect-source-checksum` and `--expect-current-checksum` tie
|
|
844
|
+
a restore to the plan it was read from, and the preview prints the exact guarded
|
|
845
|
+
command. Underneath, the source is re-read and re-verified under the recovery
|
|
846
|
+
lock, the staged copy is re-validated, and both inputs are re-hashed immediately
|
|
847
|
+
before the single rename. Anything that moved refuses with the registry
|
|
848
|
+
byte-identical and tells the operator to re-run the preview.
|
|
849
|
+
- **A repeat restore is a byte no-op.** Bytes already equal to the source mean no
|
|
850
|
+
rename, no preserved copy, and no marker write.
|
|
851
|
+
- **Restore establishes the boundary.** Restoring into a state directory with no
|
|
852
|
+
marker publishes one, so a later loss on that machine reads as state loss rather
|
|
853
|
+
than as a fresh first use.
|
|
854
|
+
- **The live tmux mirror is evidence, never a source.** When no verified copy
|
|
855
|
+
exists, the plan reports what identity the *exact* server can still testify to —
|
|
856
|
+
mirrored Project/Window/Pane uids, names, the Project root, and containment
|
|
857
|
+
resolved from stable tmux ids — beside a fixed statement of what no mirror can
|
|
858
|
+
return: offline resources, every Agent (no tmux option carries an Agent uid),
|
|
859
|
+
an Agent-owned Pane's `ownerRef`, the name reservation table,
|
|
860
|
+
`spec.anchorPaneRef`, `spec.defaultShellPaneRef`, and labels/annotations/timestamps/status. A pane carrying
|
|
861
|
+
a provider option is counted as proof that an Agent existed whose own uid is
|
|
862
|
+
nowhere on the server. Nothing is imported and no registry is generated:
|
|
863
|
+
rebuilding from fragments would convert a visible loss into an invisible one.
|
|
864
|
+
- **No transport is a reason, not an error.** A restore is a filesystem
|
|
865
|
+
operation, so planning works outside tmux; the mirror section simply reports
|
|
866
|
+
that it has no exact target. The diagnostic is also skipped entirely when a
|
|
867
|
+
verified copy exists or the registry is healthy, so it never answers a question
|
|
868
|
+
nobody asked.
|
|
869
|
+
|
|
870
|
+
Resolved resource graph (`internal/core/resourcegraph`):
|
|
871
|
+
|
|
872
|
+
- **One join, consumed by everything.** The Registry is the source of truth for
|
|
873
|
+
managed identity and logical desired topology; a runtime observation is a status
|
|
874
|
+
overlay. `Resolve(registry, inventory)` produces the typed read model that the
|
|
875
|
+
controller, the runtime diagnostics surface, and the primary UI all consume, so
|
|
876
|
+
"is this Window live" and "may I mutate this pane" have one answer instead of
|
|
877
|
+
one per call site.
|
|
878
|
+
- **Rows come from the Registry, objects come from the machine.** Every Registry
|
|
879
|
+
row is emitted whatever the observation said, and every observed tmux object is
|
|
880
|
+
named and classified even when projmux owns none of it. Neither direction can
|
|
881
|
+
delete or invent the other's members.
|
|
882
|
+
- **Exact evidence only.** Attribution uses mirrored uids, the mirrored owner uid,
|
|
883
|
+
the exact session role value, and the stable containment ids tmux itself
|
|
884
|
+
reports. Session name, working directory, and running command are never
|
|
885
|
+
ownership keys: a heuristic merge here would attach an operator's unrelated
|
|
886
|
+
shell to a managed resource, and a wrong identity is worse than an unattributed
|
|
887
|
+
object.
|
|
888
|
+
- **Closed attribution set.** `managed` is a Registry resource, or the object bound
|
|
889
|
+
to one; `recoverable` mirrors a uid this Registry does not contain; `control` is
|
|
890
|
+
an app-owned session carrying the exact `@projmux_session_role=control` marker;
|
|
891
|
+
`ephemeral` is an auto-attach scratch session; `unattributed` has no mirrored
|
|
892
|
+
identity but sits inside a managed enclosure or on a server projmux started;
|
|
893
|
+
`foreign` has neither and belongs to the operator's own tmux; `conflict` is
|
|
894
|
+
evidence that contradicts itself.
|
|
895
|
+
- **Contradiction refuses to bind.** One uid claimed by two live objects, a uid
|
|
896
|
+
mirrored onto the wrong kind of object, and a claim whose live containment names
|
|
897
|
+
a different owner than the Registry does are all recorded as conflicts with both
|
|
898
|
+
tmux handles, and the row is never reported live and never handed a transport
|
|
899
|
+
handle. Absent containment evidence is not a contradiction: a session that lost
|
|
900
|
+
its Project option says nothing about ownership, so the object's own exact uid
|
|
901
|
+
still binds. A binding that would cross a Project boundary is impossible by
|
|
902
|
+
construction.
|
|
903
|
+
- **Status is derived, never stored.** `missing-root` outranks every runtime
|
|
904
|
+
answer, a bound handle is `live`, a scope that could not be observed is
|
|
905
|
+
`unknown` with a stated reason, and only a readable observation with no handle is
|
|
906
|
+
`offline`. An empty or failed observation can only downgrade a row; it can never
|
|
907
|
+
invent a live one. An Agent has no tmux object of its own, so its status is its
|
|
908
|
+
current managed Pane's status and its phase is reported from the Registry
|
|
909
|
+
verbatim.
|
|
910
|
+
- **Partial failure stays partial.** The host-ownership probe and the three list
|
|
911
|
+
queries are independent scopes. A failed windows query leaves Window rows
|
|
912
|
+
`unknown` while Pane rows keep their own observation, because a pane that is
|
|
913
|
+
provably gone is still offline. A socket with no server behind it is different
|
|
914
|
+
again: that is definite knowledge that nothing is live, so rows read `offline`
|
|
915
|
+
and only host ownership is unavailable.
|
|
916
|
+
- **Both hosts, one identity.** `@projmux_app=1` on the server is the only proof
|
|
917
|
+
of an app-owned host; anything else is a standalone host projmux is a guest on.
|
|
918
|
+
The same Registry and the same objects produce identical managed rows under both,
|
|
919
|
+
and a control-role marker on a server projmux does not own is refused, because
|
|
920
|
+
any process can set an option on the operator's tmux.
|
|
921
|
+
- **Explicit transport or none.** An observation is routed through exactly one
|
|
922
|
+
`-L <name>` or `-S <absolute path>`, resolved from the explicit socket flags
|
|
923
|
+
first and the inherited `$TMUX` socket path second. There is no implicit
|
|
924
|
+
default-server probe: with no transport the graph is a Registry-only snapshot
|
|
925
|
+
whose runtime answers are all `unknown`, and a sibling socket is never read.
|
|
926
|
+
- **Bounded and pure.** One observation costs one option probe plus three list
|
|
927
|
+
queries whatever the size of the server, is memoized for the invocation rather
|
|
928
|
+
than cached with a TTL — closing a pane must make the *next* command report it
|
|
929
|
+
offline — and issues no write verb. `Resolve` itself touches no filesystem, no
|
|
930
|
+
process, and no tmux, so the same inputs always produce byte-identical output
|
|
931
|
+
and a read can never materialize state.
|
|
932
|
+
|
|
313
933
|
Session State interoperability:
|
|
314
934
|
|
|
315
935
|
- Session snapshots carry resource identity through additive `omitempty`
|
|
@@ -318,10 +938,13 @@ Session State interoperability:
|
|
|
318
938
|
`uid`, `name`, `labels`, `owner_kind`, and `owner_uid` in the snapshot's own
|
|
319
939
|
snake_case spelling. No schema bump was needed, and a snapshot written
|
|
320
940
|
without resource metadata still serializes byte-identically to the older form.
|
|
321
|
-
- Snapshots written before resource metadata existed still
|
|
322
|
-
deterministically
|
|
323
|
-
|
|
324
|
-
|
|
941
|
+
- Snapshots written before resource metadata existed still project
|
|
942
|
+
deterministically into an explicitly selected, closed Registry Project:
|
|
943
|
+
existing Windows and Panes are reused positionally in Registry order and any
|
|
944
|
+
additional descendants receive new stable identities. Restore validates a
|
|
945
|
+
pure Project-scoped plan, atomically commits that desired subtree, and only
|
|
946
|
+
then invokes the ordinary Project materializer. It never directly replays
|
|
947
|
+
snapshot topology into tmux and never replaces the global Registry.
|
|
325
948
|
|
|
326
949
|
tmux transport mirror:
|
|
327
950
|
|
|
@@ -331,6 +954,21 @@ tmux transport mirror:
|
|
|
331
954
|
`@projmux_pane_uid` plus the existing `@projmux_pane_label` as the Pane
|
|
332
955
|
**name** mirror. These are the first window-scoped projmux options; every
|
|
333
956
|
earlier one was pane-, session-, or global-scoped.
|
|
957
|
+
- Opening an unregistered directory is the gesture that mints a Project, and the
|
|
958
|
+
same flow finishes that Project's identity mirror. The first open takes the
|
|
959
|
+
shipped `EnsureSession` path -- which writes only the `@projmux_project_path`
|
|
960
|
+
anchor -- so the open itself writes `@projmux_project_uid` and
|
|
961
|
+
`@projmux_project_name` onto the session it just created, after the session
|
|
962
|
+
exists and before the client moves. It uses the same `MirrorProject` writer
|
|
963
|
+
every other mirror goes through, on the same plain `tmux` transport the session
|
|
964
|
+
was created on. The write is gated strictly on "this open registered the
|
|
965
|
+
Project": every already-registered Project converges through the Registry
|
|
966
|
+
topology engine, including desired state previously committed from a snapshot,
|
|
967
|
+
and opening `$HOME` mints no managed identity at all, so neither writes a mirror
|
|
968
|
+
option through this first-open gate. That gate is also what makes repeating an
|
|
969
|
+
open write nothing. Repairing a session that is already live without its
|
|
970
|
+
identity mirror is not this path's job: `projmux reconcile resources` is the
|
|
971
|
+
recovery route.
|
|
334
972
|
- `rename pane` changes `Pane.metadata.name` and its `@projmux_pane_label`
|
|
335
973
|
mirror only. It never writes the raw tmux `pane_title`.
|
|
336
974
|
- `rename window` is the explicit stable-identity path: it changes only
|
|
@@ -440,6 +1078,13 @@ Runtime observation and resource status:
|
|
|
440
1078
|
exact mirror is killed before the Registry commit; duplicate, foreign,
|
|
441
1079
|
stale-owner, inventory-failure, and plan-to-execution race states remain
|
|
442
1080
|
fail-closed. An implicit active Window is never treated as offline.
|
|
1081
|
+
- **`delete window|pane|agent` names its server the same way `reconcile
|
|
1082
|
+
resources` does**: explicit `--socket <name>`, explicit `--socket-path
|
|
1083
|
+
<absolute>`, or the inherited absolute `$TMUX`, and outside tmux with no flag
|
|
1084
|
+
it refuses. There used to be a fourth branch -- a hardcoded `-L projmux` --
|
|
1085
|
+
which meant a delete issued against an isolated server inventoried one host
|
|
1086
|
+
and killed objects on another. Refusing is the only remaining honest answer,
|
|
1087
|
+
and it names the two flags that fix it.
|
|
443
1088
|
- The inventory is a pure **read**. It never writes, re-mirrors, or adopts a uid
|
|
444
1089
|
onto a live tmux object; reattaching a lost binding belongs to the reconciler
|
|
445
1090
|
(see *Binding reapply and adoption* below). After a tmux server restart the
|
|
@@ -541,20 +1186,94 @@ Binding reapply and adoption:
|
|
|
541
1186
|
step, or that step would stamp `MissingRuntime` on a Window this same pass
|
|
542
1187
|
just reattached.
|
|
543
1188
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
-
|
|
1189
|
+
Lifecycle trigger convergence:
|
|
1190
|
+
|
|
1191
|
+
- Every mutation and lifecycle producer reaches **one** entrypoint. A producer
|
|
1192
|
+
states a reason from a closed set (`config-apply`, `runtime-created`,
|
|
1193
|
+
`runtime-exited`) and one exact tmux server; it does not choose which stages
|
|
1194
|
+
run or in what order. `projmux config apply --socket <name>` reaches it after
|
|
1195
|
+
config preflight and a successful `source-file`; the generated config's
|
|
1196
|
+
`after-new-window`, `after-split-window`, `pane-exited`, and `after-kill-pane`
|
|
1197
|
+
hooks reach it through the hidden `internal tmux converge` route, which is the
|
|
1198
|
+
only lifecycle route there is.
|
|
1199
|
+
- The two exit hooks are in both generated configs; the two creation hooks are
|
|
1200
|
+
app-config only, and that asymmetry is the adoption boundary rather than an
|
|
1201
|
+
oversight. A convergence caused by a *new* runtime object mints and rebinds, so
|
|
1202
|
+
it adopts an unmarked window inside a managed enclosure. On the app-owned server
|
|
1203
|
+
every session is projmux's own and there is nothing to adopt by accident; the
|
|
1204
|
+
standalone snippet is sourced from the operator's `~/.tmux.conf` and therefore
|
|
1205
|
+
runs on every server they start, where a raw `new-window` in a session projmux
|
|
1206
|
+
does not own has to stay an unmanaged runtime object that only the Runtime
|
|
1207
|
+
diagnostics surface shows.
|
|
1208
|
+
- A hook states that something on one exact server may have changed. Exact
|
|
1209
|
+
`pane-exited` additionally carries tmux's `%N` Pane and `@N` Window handles;
|
|
1210
|
+
kill and coalesced triggers deliberately carry no invented identity. Every
|
|
1211
|
+
hook expands tmux's own absolute `#{socket_path}` and `#{session_id}`, so
|
|
1212
|
+
neither the route nor the convergence it drives falls back to the default
|
|
1213
|
+
socket or inherited `$TMUX`.
|
|
1214
|
+
- One convergence pass is one locked reconciliation followed by an exit-half
|
|
1215
|
+
reobservation. The reconciliation imports the live sessions it can attribute,
|
|
1216
|
+
reapplies the bindings it can prove, projects the lifecycle of every managed
|
|
1217
|
+
Pane whose runtime object died, and records why a Window or Pane lost one --
|
|
1218
|
+
all inside one registry transaction against one observation taken inside the
|
|
1219
|
+
lock. The projection has to run *after* the binding steps of the same pass:
|
|
1220
|
+
those steps mirror the uids the observation is diffed against, so an exit stage
|
|
1221
|
+
placed first would file an unknown termination against every Pane the pass was
|
|
1222
|
+
on its way to binding.
|
|
1223
|
+
- At most one worker converges one exact server at a time, held as an advisory
|
|
1224
|
+
whole-file lease under `<state>/projmux/controller/`. Not because two would
|
|
1225
|
+
corrupt anything -- the registry's own lock prevents that -- but because both
|
|
1226
|
+
pane-exit hooks fire on every pane exit in every session, and a fleet of
|
|
1227
|
+
workers contending for one registry lock is how a burst becomes lock-attempt
|
|
1228
|
+
exhaustion instead of a convergence. A producer that loses the lease records
|
|
1229
|
+
its dirty event and exits successfully; the holder has not acknowledged that
|
|
1230
|
+
event yet, so it runs a further pass for it. The lease is `flock` rather than a
|
|
1231
|
+
timestamped lockfile so a worker that is killed or panics leaves nothing to
|
|
1232
|
+
break.
|
|
1233
|
+
- The pass repeats until one of them writes nothing. That final no-op pass is the
|
|
1234
|
+
reobservation: a write that landed and did not converge -- a second client
|
|
1235
|
+
racing the same repair, a hook that rewrote an option back -- is exactly what a
|
|
1236
|
+
report claiming success must not hide. The loop is bounded; stopping early is
|
|
1237
|
+
safe in a way a lost event is not, because convergence is derived from the
|
|
1238
|
+
machine rather than from the event log.
|
|
1239
|
+
- Project runtime stop and fresh identity separation Phase 1 pairs a qualifying
|
|
1240
|
+
last-Pane `pane-exited` with the exact matching `window-unlinked` by socket,
|
|
1241
|
+
`$N` session, `@N` Window, `%N` Pane, Registry
|
|
1242
|
+
owner chain, and activation generation. The first event stores only bounded
|
|
1243
|
+
teardown evidence; the second re-observes every Window in that exact session,
|
|
1244
|
+
including unmirrored siblings, before deleting anything. The guarded
|
|
1245
|
+
transaction deletes exactly that Window, its Panes, its owned Agents, and
|
|
1246
|
+
their reservations. A non-last Project Window reanchors to its existing
|
|
1247
|
+
sibling. The last Project Window leaves the exact Project uid, root,
|
|
1248
|
+
reservation, pins, and snapshot bytes in the valid zero-Window state. A
|
|
1249
|
+
ControlSession likewise loses only the Window and keeps its root uid.
|
|
1250
|
+
Abnormal/killed/unknown exits, stale generations, unpaired or foreign handles,
|
|
1251
|
+
unavailable/empty observations, and missing-server or permission failures
|
|
1252
|
+
retain the graph. A historical offline Window without the stored causal Pane
|
|
1253
|
+
receipt is never deleted from absence alone; its fixed diagnostic recovery is
|
|
1254
|
+
an exact canonical `delete window uid:<window-uid>` on the named socket. No
|
|
1255
|
+
pane content, command, prompt, history, or transcript is an authority input.
|
|
1256
|
+
- Phase 2 gives Project startup and stop one closed lifecycle table. The input
|
|
1257
|
+
states are retained-window, zero-window, and deleted; the actions are Stop,
|
|
1258
|
+
Continue, Fresh, and explicit Project delete. Stop writes only the exact
|
|
1259
|
+
managed runtime and preserves every desired UID. Continue on retained-window
|
|
1260
|
+
writes only runtime and materializes the same descendant UIDs; Continue on
|
|
1261
|
+
zero-window atomically allocates one canonical Window/shell below the same
|
|
1262
|
+
Project UID before runtime materialization. The deleted+Continue cell accepts
|
|
1263
|
+
only a usable-snapshot precondition, then atomically creates a new Project UID
|
|
1264
|
+
and restores new descendant UIDs from that snapshot; without the precondition
|
|
1265
|
+
the same cell is an unavailable zero-write refusal and never falls back to
|
|
1266
|
+
Fresh. Fresh atomically replaces either
|
|
1267
|
+
registered state with a new Project/Window/shell UID chain and exactly one
|
|
1268
|
+
same-root claimant. Within this runtime/startup lifecycle table, canonical
|
|
1269
|
+
`delete project --yes` alone unregisters the Project graph; the separately
|
|
1270
|
+
scoped filesystem-missing `prune project` administrative policy is unchanged.
|
|
1271
|
+
Ordinary close-window is a separate operation class, so no one plan can also
|
|
1272
|
+
be stop, Fresh, or Project delete.
|
|
1273
|
+
- The creation hooks stay synchronous so a newly bindable Window or Pane has a
|
|
556
1274
|
registry binding before the creating tmux command returns and before the next
|
|
557
|
-
implicit read can run
|
|
1275
|
+
implicit read can run; the exit hooks stay backgrounded so closing a pane never
|
|
1276
|
+
waits on convergence. Mirror writes use `set-option` and `rename-window`, not
|
|
558
1277
|
creation commands, so they cannot recursively fire either creation hook.
|
|
559
1278
|
- A canonical resource create already owns the registry transaction while it
|
|
560
1279
|
issues `new-window` or `split-window`. It therefore installs a private,
|
|
@@ -589,6 +1308,276 @@ Managed runtime binding convergence:
|
|
|
589
1308
|
registry schema. It does not add persistent Project scope, matching by name,
|
|
590
1309
|
cwd, or a new ordinal heuristic, uid merge/reassignment, pruning, or forced
|
|
591
1310
|
adoption. The Project scope remains derived from the active binding on read.
|
|
1311
|
+
- There is no daemon and no auto-start. A trigger never resumes an Agent, never
|
|
1312
|
+
materializes an offline resource, and never adopts or deletes an unmanaged
|
|
1313
|
+
runtime object. Read verbs start no controller at all: `get`, `describe`, and
|
|
1314
|
+
implicit active-target resolution neither converge nor open a registry
|
|
1315
|
+
transaction, and they leave no controller event or lease behind.
|
|
1316
|
+
|
|
1317
|
+
Projmux split UI:
|
|
1318
|
+
|
|
1319
|
+
- Every split producer carries the exact popup origin as a typed canonical
|
|
1320
|
+
create intent. The origin must resolve through the mirrored Pane uid to its
|
|
1321
|
+
owner Window uid and then to exactly one Project or ControlSession uid; those
|
|
1322
|
+
Phase 11 declaration, root, role, Window, and Pane mirrors are the complete
|
|
1323
|
+
identity evidence. A ControlSession Pane's cwd is launch workspace only and
|
|
1324
|
+
never participates in root identity. If the origin disappears or its owner
|
|
1325
|
+
chain conflicts, canonical create performs no Registry or tmux write and
|
|
1326
|
+
projects the exact refusal to the originating tmux client.
|
|
1327
|
+
- The default `ai-split-right/down` binding, the `Alt-7` provider picker, the
|
|
1328
|
+
resume picker, and the provider and shell direct actions all produce a
|
|
1329
|
+
canonical create intent -- which provider, which side, and for a resume which
|
|
1330
|
+
conversation -- and hand it to the same `create` route a typed command reaches.
|
|
1331
|
+
The provider and shell branches render the exact argv an operator would type,
|
|
1332
|
+
so a UI action and a typed command cannot disagree about what `--placement
|
|
1333
|
+
down` means.
|
|
1334
|
+
- Only the materializer runs `split-window`. Before this convergence the saved
|
|
1335
|
+
default and both pickers descended into a legacy split that called tmux
|
|
1336
|
+
directly, so a pane opened from the UI was a runtime object the Registry had
|
|
1337
|
+
never heard of: no uid, no owner Window, no Agent row, and a Main UI row only
|
|
1338
|
+
once something else happened to reconcile. A raw unmanaged split now exists
|
|
1339
|
+
only where the operator makes one -- typing `tmux split-window`, or tmux's own
|
|
1340
|
+
pane-context-menu entries.
|
|
1341
|
+
- The saved split mode is the one piece of hidden state the split UI reads, which
|
|
1342
|
+
is why the canonical `create agent` route refuses to read it: a canonical route
|
|
1343
|
+
whose result depends on state the operator cannot see in the argv is not
|
|
1344
|
+
canonical. A saved mode that names no launch opens the picker; a saved provider
|
|
1345
|
+
that Settings has since disabled fails clearly, before the intent exists, with
|
|
1346
|
+
zero Registry and zero tmux mutations.
|
|
1347
|
+
- The resume picker joins a conversation the machine already has by reaching the
|
|
1348
|
+
same Agent allocation with the provider's *resume* argv substituted for its
|
|
1349
|
+
fresh-start argv. It is not `agent resume`: that verb rebinds an existing
|
|
1350
|
+
Registry Agent and must never fall through to a fresh conversation, while this
|
|
1351
|
+
interactive path may, because the operator picked a row and has already been
|
|
1352
|
+
told it could not be resumed.
|
|
1353
|
+
|
|
1354
|
+
Command-scoped controller kernel:
|
|
1355
|
+
|
|
1356
|
+
- One seam runs the whole sequence: observe one exact server, resolve it into a
|
|
1357
|
+
`resourcegraph.Graph`, plan, commit the Registry, guard tmux, execute, and
|
|
1358
|
+
reobserve. It is command-scoped and event-triggerable; there is no daemon.
|
|
1359
|
+
- Authority is a closed table over intent x attribution plus one explicit grant,
|
|
1360
|
+
not a predicate. The grant is `OperatorTargeted`: this invocation names one
|
|
1361
|
+
exact server the operator chose. `reconcile resources` cannot run without such
|
|
1362
|
+
a target, and that selection -- nothing else -- is what makes an unmarked
|
|
1363
|
+
object on a host projmux does not own repairable. Without the grant `foreign`
|
|
1364
|
+
is refused, and with it every lifecycle intent still is.
|
|
1365
|
+
`start`, `import`, and `delete` are refused for every class, so an offline
|
|
1366
|
+
resource, Home, an ephemeral session, and an unattributed Pane cannot be
|
|
1367
|
+
created, adopted, or removed by convergence. Repair is allowed on `managed`
|
|
1368
|
+
and on `unattributed` -- an unmarked object inside projmux's own runtime world
|
|
1369
|
+
carries no competing identity, so restoring a Registry-owned mirror overwrites
|
|
1370
|
+
nobody. `recoverable`, `foreign`, and `conflict` are refused; `control` and
|
|
1371
|
+
`ephemeral` are observe-only. An unknown class fails closed.
|
|
1372
|
+
- A planned write must also carry one of the two convergence verbs,
|
|
1373
|
+
`set-option` or `rename-window`. The verb gate is what makes "convergence
|
|
1374
|
+
never created or killed a runtime object" structural rather than a property of
|
|
1375
|
+
which candidates happen to exist today.
|
|
1376
|
+
- The plan is totally ordered: registry surface before tmux surface, then
|
|
1377
|
+
outermost containment first, then by stable key. Containment order is load
|
|
1378
|
+
bearing -- a Pane uid written into a Window that does not yet carry its own uid
|
|
1379
|
+
is attributable to nothing, and the next pass reads it as a Pane outside its
|
|
1380
|
+
owner scope.
|
|
1381
|
+
- Guards are exact evidence captured at observation time and re-proved
|
|
1382
|
+
immediately before the first live write, all or nothing: the server's own
|
|
1383
|
+
`#{socket_path}`, the target's mirrored uid, and the containing object's id.
|
|
1384
|
+
A stale guard aborts having written nothing and reports the exact retry.
|
|
1385
|
+
- After a run that changed anything, the kernel replans against fresh bytes and
|
|
1386
|
+
reports whether a repeat would write. Convergence is observed, not assumed.
|
|
1387
|
+
- Explicit topology materialization keeps its own engine and its own
|
|
1388
|
+
plan-time guard, because it plans against objects it is about to create, which
|
|
1389
|
+
no prior observation can have seen.
|
|
1390
|
+
|
|
1391
|
+
Runtime diagnostics escape hatch:
|
|
1392
|
+
|
|
1393
|
+
- A Registry-first surface is not an inventory, and that is the point of this
|
|
1394
|
+
one. The managed UI lists Registry resources, so an operator's own shell, the
|
|
1395
|
+
Home control session, a scratch session, and anything on a server projmux is a
|
|
1396
|
+
guest on are all correctly absent from it -- and "correctly absent" is
|
|
1397
|
+
indistinguishable from "lost" without a surface that shows the machine as it
|
|
1398
|
+
is. `projmux get runtime sessions|windows|panes` and the `projmux runtime
|
|
1399
|
+
diagnostics` picker are that surface.
|
|
1400
|
+
- It is a projection of `resourcegraph`, not a second join. Every row comes from
|
|
1401
|
+
the resolved graph, which already decided attribution from exact uid, owner,
|
|
1402
|
+
and role evidence; nothing here re-derives a class and nothing here consults a
|
|
1403
|
+
session name, a working directory, or a running command. Every observed object
|
|
1404
|
+
is emitted, managed ones included, because a managed object that needs no
|
|
1405
|
+
repair is exactly the row an operator looks for when the managed UI shows it
|
|
1406
|
+
and the machine seems not to.
|
|
1407
|
+
- Two handles per row, and they are not interchangeable. The stable tmux id is
|
|
1408
|
+
the only thing worth storing; the qualified coordinate -- a session name,
|
|
1409
|
+
`<session>:@N`, `<session>:@N.%N` -- is what an operator and the focus route
|
|
1410
|
+
address the object by. The session half of a coordinate degrades from the
|
|
1411
|
+
observed name to the `$N` id, and an object whose enclosing session cannot be
|
|
1412
|
+
resolved gets no coordinate at all rather than an unqualified handle the focus
|
|
1413
|
+
grammar would read as a session name.
|
|
1414
|
+
- One exact host, and no transport is an answer. The routing is an explicit
|
|
1415
|
+
`--socket`/`--socket-path` or the inherited `$TMUX` socket path, never a
|
|
1416
|
+
default-server probe and never a second socket. Outside tmux the read succeeds
|
|
1417
|
+
and reports every scope unavailable with a stated reason, where `reconcile
|
|
1418
|
+
resources` refuses the same case because it is about to write.
|
|
1419
|
+
- The whole surface is read-only. The Registry is opened without creating it,
|
|
1420
|
+
the observation is the bounded four-query adapter that owns no write verb, and
|
|
1421
|
+
the projection is pure, so a refresh is indistinguishable from not having run
|
|
1422
|
+
it. An empty item list next to a populated unavailability list is a different
|
|
1423
|
+
answer from an empty item list beside none.
|
|
1424
|
+
- The picker's actions are forwards, not features. `focus` moves a client and
|
|
1425
|
+
never materializes, `attach project` is the outside-tmux Project entry point,
|
|
1426
|
+
and the Resource Inspector is read-only; each is offered only where it
|
|
1427
|
+
applies, and where it does not the row states why. There is deliberately no
|
|
1428
|
+
adopt, import, rename, or kill: a diagnostic surface that could adopt what it
|
|
1429
|
+
found would be the heuristic merge the resolved graph refuses, wearing a menu.
|
|
1430
|
+
- `projmux runtime diagnostics` stays separate from `projmux runtime sessions`.
|
|
1431
|
+
That picker lists recent sessions to open one; this one lists every object on
|
|
1432
|
+
the server to explain what it is. Merging them would put an operator's own
|
|
1433
|
+
shell into the open-a-session list.
|
|
1434
|
+
|
|
1435
|
+
Registry-first primary navigation:
|
|
1436
|
+
|
|
1437
|
+
- The primary surfaces enumerate the Registry, not the machine. `internal/core/
|
|
1438
|
+
registryview` builds their rows from a resolved graph, so a Project is a row
|
|
1439
|
+
because the Registry contains it and not because a tmux session exists. The
|
|
1440
|
+
runtime contributes a status -- live, offline, missing-root, or unknown -- and
|
|
1441
|
+
an exact handle, and nothing else.
|
|
1442
|
+
- Identity is the Registry's. Membership and order in `registryview` are the
|
|
1443
|
+
Registry's own slice order, which is insertion order, and the pure view model
|
|
1444
|
+
applies no preference of its own: the same Registry projects the same rows in
|
|
1445
|
+
the same order on an app-owned server, on a standalone server, and outside tmux
|
|
1446
|
+
entirely.
|
|
1447
|
+
- Presentation order is the sidebar's, and only the sidebar's. The Projects list
|
|
1448
|
+
projects the managed rows onto three tiers -- pinned, then live, then closed --
|
|
1449
|
+
and preserves Registry order inside each tier as a stable tie-break. Pinned
|
|
1450
|
+
outranks live because a pin is a stated preference and liveness is an accident
|
|
1451
|
+
of the moment, so a pinned offline Project stays above an unpinned live one. The
|
|
1452
|
+
live tier is an overlay of one exact host, which makes two things contractual:
|
|
1453
|
+
the tier of a row may differ between hosts and between refreshes, and the
|
|
1454
|
+
selection may not follow a position. It follows the Project uid -- the old
|
|
1455
|
+
selection is resolved to its Project and that Project back to whatever row it
|
|
1456
|
+
renders as now -- so a tier change moves the row and not the resource the cursor
|
|
1457
|
+
is on. Nothing about a tier reaches the Registry: it is not stored, not
|
|
1458
|
+
reconciled, and not part of desired topology.
|
|
1459
|
+
- Row identity is the resource uid. A managed Project's *selection* is still its
|
|
1460
|
+
`spec.root` so the shipped open flow is unchanged, except for a Project whose
|
|
1461
|
+
root is gone: that row carries `uid:<uid>` and selecting it opens the read-only
|
|
1462
|
+
resource surface, which is where rebind is stated. Before this, such a row
|
|
1463
|
+
failed the whole picker on directory validation.
|
|
1464
|
+
- Filesystem discovery is kept and demoted. A discovered directory that no
|
|
1465
|
+
Project root claims is an unregistered bootstrap candidate in its own section;
|
|
1466
|
+
one that is already a Project root is dropped rather than listed twice with a
|
|
1467
|
+
second set of actions. Opening a candidate is the explicit gesture that
|
|
1468
|
+
registers it -- see the authority split below.
|
|
1469
|
+
- Home is chrome, not a Project, and the three senses of "Home" stay separate;
|
|
1470
|
+
*Home and root kinds* under the resource metadata model is the canonical
|
|
1471
|
+
statement and this row-level detail follows from it. The
|
|
1472
|
+
Home *control session* is never a managed row: the tmux session itself is app
|
|
1473
|
+
control runtime with no `resourceRef`, the only evidence that a session is one
|
|
1474
|
+
is the exact `@projmux_session_role` value the graph reads, and a session named
|
|
1475
|
+
`home` with no marker is honestly unattributed. The marker is written by the
|
|
1476
|
+
canonical `projmux shell` entry, for the app-session target only, and the same
|
|
1477
|
+
pass mirrors Home's Window and Pane identity -- so Home's *windows and panes*
|
|
1478
|
+
are managed rows owned by a `ControlSession`, while the session row itself
|
|
1479
|
+
stays `control`. Home is still not a Project and never appears in
|
|
1480
|
+
`get projects`. The Home *navigation row* is the operator's own root as
|
|
1481
|
+
filesystem discovery offers it, and it leads the Projects list because it is
|
|
1482
|
+
where the surface starts from rather than a member of what the surface orders.
|
|
1483
|
+
It is synthesized from nothing: it carries no managed identity, it is not a
|
|
1484
|
+
reconcile or create target, and if discovery does not offer `$HOME` there is no
|
|
1485
|
+
Home row.
|
|
1486
|
+
- The Sessions and Recent Windows surfaces list managed rows only, attributed by
|
|
1487
|
+
tmux's own `$N` and `@N` ids rather than by a name join, and carry the Registry
|
|
1488
|
+
resource name beside the exact tmux handle their actions target. What they
|
|
1489
|
+
withhold is tallied by class on a Runtime link that forwards to the escape
|
|
1490
|
+
hatch above.
|
|
1491
|
+
- The Projects sidebar's Runtime link is conditional, and only the link is.
|
|
1492
|
+
`Settings > Projects > Project Sidebar > Runtime diagnostics` chooses between
|
|
1493
|
+
`Always`, which is the shipped behavior, and `When needed`, which is the
|
|
1494
|
+
read-time default with nothing saved and no install migrated to it. `When
|
|
1495
|
+
needed` offers the row when the refused classes -- `Unattributed`, `Foreign`,
|
|
1496
|
+
`Recoverable`, `Conflict` -- sum above zero, or when the observation could not
|
|
1497
|
+
be taken: no transport, or any scope the inventory marked unavailable. Not
|
|
1498
|
+
being able to look is not the same as nothing being there, so a failed
|
|
1499
|
+
observation keeps the escape hatch reachable rather than hiding it.
|
|
1500
|
+
`Control` and `Ephemeral` are deliberately outside that sum: the app's own
|
|
1501
|
+
control session and a scratch session are what a healthy host looks like, and
|
|
1502
|
+
counting them would put the row back on every render. The decision is purely
|
|
1503
|
+
presentational -- `registryview` still emits a complete Runtime row and a
|
|
1504
|
+
complete class tally, a visible row carries its exact shipped label and tally,
|
|
1505
|
+
the Sessions and Recent Windows links are untouched, and `projmux runtime
|
|
1506
|
+
diagnostics` and `projmux get runtime ...` never read the preference. Hiding a
|
|
1507
|
+
row is not disabling a capability. An unreadable or unrecognized saved value
|
|
1508
|
+
resolves to `When needed` without writing anything and says so in Settings.
|
|
1509
|
+
- Every action forwards to a route that already owns it: `focus` for a live row,
|
|
1510
|
+
`attach project` for an offline Project -- the one shipped route that
|
|
1511
|
+
materializes one -- and `agent resume` for an Agent. Rebind and delete are
|
|
1512
|
+
listed as eligible with the exact command that performs them rather than
|
|
1513
|
+
executed from a read surface.
|
|
1514
|
+
- A navigation refresh is a read. It opens the Registry read-only, takes the
|
|
1515
|
+
bounded four-query observation through one exact socket, and projects it
|
|
1516
|
+
purely: no Registry or tmux write, no reconcile, no materialize, and no
|
|
1517
|
+
default-server probe when there is no transport.
|
|
1518
|
+
|
|
1519
|
+
Project discovery and pin authority:
|
|
1520
|
+
|
|
1521
|
+
Five things used to share two files, and each of them answered a different
|
|
1522
|
+
question wrongly as a result. Workdirs were a scan source *and* the thing that
|
|
1523
|
+
decided which Projects existed. The pin file was a presentation preference *and*
|
|
1524
|
+
a discovery input *and* the only record that a directory mattered. They are five
|
|
1525
|
+
separate authorities now, and the boundaries are the point.
|
|
1526
|
+
|
|
1527
|
+
- **Workdirs and project roots are scan roots.** `PROJMUX_MANAGED_ROOTS`,
|
|
1528
|
+
`PROJMUX_PROJDIR` and `~/.config/projmux/workdirs` name directories to look
|
|
1529
|
+
inside. Looking inside a directory registers nothing. On Windows they are
|
|
1530
|
+
OS-native paths and stay OS-native paths; nothing normalizes them into identity.
|
|
1531
|
+
- **A discovered child is an unregistered candidate.** It is a filesystem fact
|
|
1532
|
+
with no uid, no name reservation, and no Registry row. It stays one until
|
|
1533
|
+
something explicitly registers it, however many times it is scanned, rendered,
|
|
1534
|
+
or reconciled.
|
|
1535
|
+
- **The Registry is managed identity.** `projmux create project --root <path>` is
|
|
1536
|
+
the canonical bootstrap, and opening a candidate from the Projects sidebar
|
|
1537
|
+
performs the same registration for that one exact path. Both go through one
|
|
1538
|
+
transaction and both are idempotent: a root an existing Project already claims
|
|
1539
|
+
is answered from the Registry and writes nothing. Nothing else registers a
|
|
1540
|
+
Project. In particular the reconcile prelude no longer walks the discovery
|
|
1541
|
+
roots, so `create pane` in one repository cannot add a Project for every
|
|
1542
|
+
sibling directory under a scan root -- which is exactly what it used to do.
|
|
1543
|
+
`--project <name>` naming an unregistered candidate is a refusal that names the
|
|
1544
|
+
exact `--root` and the route that would register it.
|
|
1545
|
+
- **A managed pin is a Registry Project uid.** Its displayed root and name are
|
|
1546
|
+
projected from the Registry on every render, so the pin survives a rebind, a
|
|
1547
|
+
rename, and a `MissingRoot` condition. The sidebar tier reads the uid, never the
|
|
1548
|
+
path.
|
|
1549
|
+
- **A candidate pin is a path no Project claims.** It is a preference about a
|
|
1550
|
+
directory, kept as one. Rendering it, listing it, and pinning it never mint a
|
|
1551
|
+
Project.
|
|
1552
|
+
|
|
1553
|
+
Storage and migration:
|
|
1554
|
+
|
|
1555
|
+
- The pin file is a typed envelope: a `projmux-pins v2` header followed by
|
|
1556
|
+
`project <uid>` and `candidate <path>` lines. The kind is stored, not inferred,
|
|
1557
|
+
which is what lets one file hold both collections without either surface having
|
|
1558
|
+
to guess.
|
|
1559
|
+
- Reading never writes. Every rendering surface projects a pre-v2 file in memory
|
|
1560
|
+
through the same resolution a migration would persist, so the sidebar is
|
|
1561
|
+
identical before and after `projmux pin project migrate`.
|
|
1562
|
+
- Migration is per-line and atomic as a whole. A path exactly one Project's root
|
|
1563
|
+
claims becomes that uid; a path no Project claims stays a candidate; a path more
|
|
1564
|
+
than one Project claims refuses the entire migration with the pin file and the
|
|
1565
|
+
Registry byte-identical, and names the repair. A corrupt or newer-version
|
|
1566
|
+
envelope is refused rather than partially parsed, because a wrong guess about
|
|
1567
|
+
which resource a preference points at is worse than declining to load one.
|
|
1568
|
+
- Path folding is confined to two questions: candidate exact-match, and legacy
|
|
1569
|
+
path-to-uid migration. `candidates.MatchKeyFor` resolves symlinks on every
|
|
1570
|
+
platform and additionally folds separator, case, and drive-letter case on
|
|
1571
|
+
Windows, so `C:\Users\dev\src` and `c:/users/dev/src` are one candidate. It is
|
|
1572
|
+
never an identity operation: no amount of path agreement mints a Project uid or
|
|
1573
|
+
merges two, and the Windows rules are frozen by a compatibility table that a
|
|
1574
|
+
Linux test run asserts.
|
|
1575
|
+
- `pin project add|remove|toggle <dir>` keeps working unchanged and now resolves
|
|
1576
|
+
to a typed pin under one rule -- exactly one Project with that root makes the pin
|
|
1577
|
+
managed, none makes it a candidate, more than one is refused -- with
|
|
1578
|
+
`uid:<uid>` available when an operator wants to be explicit. Settings shows the
|
|
1579
|
+
three collections as three collections: Additional discovery roots, Pinned
|
|
1580
|
+
Projects, and Candidate Pins.
|
|
592
1581
|
|
|
593
1582
|
Public resource reconciliation:
|
|
594
1583
|
|
|
@@ -603,16 +1592,22 @@ Public resource reconciliation:
|
|
|
603
1592
|
normalized to deterministic placeholders; they are not matching keys and do
|
|
604
1593
|
not obscure owner or target identity. Human and JSON output share the same
|
|
605
1594
|
sorted items and missing/stale/foreign/orphan vocabulary.
|
|
606
|
-
- Execute rebuilds the plan from the
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1595
|
+
- Execute runs through the controller kernel. It rebuilds the plan from the
|
|
1596
|
+
locked current Registry and authorizes every runtime write against the graph
|
|
1597
|
+
resolved from the pre-lock observation. Runtime observation is limited to the
|
|
1598
|
+
Registry Project graphs safely attributable to sessions on the selected
|
|
1599
|
+
socket; absence there never marks another socket's graph missing or releases
|
|
1600
|
+
its Agents. The desired Registry is validated and committed before any
|
|
1601
|
+
non-transactional tmux mirror write, keeping Registry identity authoritative
|
|
1602
|
+
and retryable if a later live step fails. After commit, the socket identity
|
|
1603
|
+
and every planned write's uid and containment guards are re-proved from the
|
|
1604
|
+
exact socket; all of them must still match before the first write. A recycled,
|
|
1605
|
+
moved, or raced handle therefore causes zero live writes.
|
|
1606
|
+
- The report is one projection consumed by both renderers. Alongside the sorted
|
|
1607
|
+
items it carries the observed host mode, the authority rows the run
|
|
1608
|
+
exercised -- including the start, import, and delete refusals that are the
|
|
1609
|
+
evidence nothing was activated or adopted -- and the post-execute
|
|
1610
|
+
reobservation.
|
|
616
1611
|
- A Registry commit failure performs no tmux mutation. A partial tmux failure
|
|
617
1612
|
leaves the durable Registry identity in place, replans current drift, and
|
|
618
1613
|
reports completed stages, remaining items, and the exact retry command.
|
|
@@ -638,17 +1633,57 @@ Explicit Registry topology materialization:
|
|
|
638
1633
|
one Registry Project and uses a separate pure plan. The default reconciliation
|
|
639
1634
|
shadow never calls the materializer, and the materialization plan never runs
|
|
640
1635
|
blank adoption, orphan minting, or Agent phase observation. Registry insertion
|
|
641
|
-
order determines session/Window/Window-owned shell Pane creation order;
|
|
642
|
-
keys provide a separately stable rendering order.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1636
|
+
order determines session/Window/Window-owned shell Pane/Agent creation order;
|
|
1637
|
+
report keys provide a separately stable rendering order. Agents are created
|
|
1638
|
+
last inside their Window. A shell or managed-Agent `anchorPaneRef` must be
|
|
1639
|
+
proven on the exact Window; no alternate live Pane is inferred.
|
|
1640
|
+
- Registry presence is desired topology. Missing runtime sessions, Windows,
|
|
1641
|
+
Window-owned `role=shell` Panes, and Agents are drift; canonical Registry
|
|
1642
|
+
deletion removes that desire. Exact uid/name/owner mirrors are retained. Stored
|
|
1643
|
+
Pane CWD drives only that Pane's detached runtime cwd, while Project root
|
|
1644
|
+
remains the session path anchor and `PROJMUX_CWD` hook value.
|
|
1645
|
+
`Pane.spec.command`, snapshot recipes, notifications, and ephemeral sessions
|
|
1646
|
+
are never execution inputs.
|
|
1647
|
+
- An Agent whose managed Pane is not live is replayed into a new managed Pane on
|
|
1648
|
+
its Window's proven anchor, through the same allocation, activation ledger,
|
|
1649
|
+
ownership-checked adoption, and rollback the shell half uses. The **only**
|
|
1650
|
+
replay identifier is Registry `status.sessionRef`: no provider conversation
|
|
1651
|
+
store is read, `ClaudeSessionRef.TranscriptPath` in particular is never
|
|
1652
|
+
consulted, and snapshot recipe `resumeID` is a separate value that never feeds
|
|
1653
|
+
this path. The launch argv comes from the two seams `create agent` already
|
|
1654
|
+
owns -- `PlanAgentResume` for a ref that names a conversation, `PlanAgentLaunch`
|
|
1655
|
+
with no payload otherwise -- so the topology engine holds no launch builder of
|
|
1656
|
+
its own and the Settings enabled-agents gate still applies. An Agent that
|
|
1657
|
+
cannot rejoin its conversation comes back on a *new* one and the reason is
|
|
1658
|
+
disclosed; an Agent that cannot be launched at all is disclosed and skipped.
|
|
1659
|
+
Neither aborts the materialization, and neither is ever silent. A stale managed
|
|
1660
|
+
Pane row is released only after the server-wide uid preflight proves its uid is
|
|
1661
|
+
live nowhere on the exact socket.
|
|
1662
|
+
- An offline Agent-only Window plans a visible `allocate default shell` Registry
|
|
1663
|
+
item, authors that direct shell under the same convergent transaction, creates
|
|
1664
|
+
the Window from it, and then replays the anchor Agent while preserving the
|
|
1665
|
+
Agent Pane uid. The default shell is bootstrap, not a replacement anchor. A
|
|
1666
|
+
successful repeat is a Registry-write-free and topology-write-free no-op.
|
|
1667
|
+
- Snapshot restore is a target-Project subtree projection, never a Registry
|
|
1668
|
+
restore. Metadata-bearing v1 snapshots preserve surviving final-v2
|
|
1669
|
+
anchor/default refs; metadata-free snapshots choose the first Window-local
|
|
1670
|
+
Pane as anchor and the first direct shell as optional default. Agent-only
|
|
1671
|
+
desired Windows remain Agent-anchored and acquire a shell only through the
|
|
1672
|
+
ordinary materializer. Source snapshot bytes and unrelated roots are never
|
|
1673
|
+
rewritten, and a second projection is byte-stable.
|
|
1674
|
+
- `Open fresh` replaces the exact same-root Project graph in one Registry
|
|
1675
|
+
commit. It always allocates a new Project UID plus one new canonical Window
|
|
1676
|
+
and direct shell UID, whether the old Project retained Windows or had zero.
|
|
1677
|
+
The preimage remains the durable recovery state when the replacement commit
|
|
1678
|
+
fails, and successful validation requires exactly one same-root claimant.
|
|
1679
|
+
- Exact-socket reconciliation merges scoped results by UID at their existing
|
|
1680
|
+
global Registry positions. Positive mirrored evidence may change only the
|
|
1681
|
+
selected socket's owned rows; sibling sockets and other-host-only desired
|
|
1682
|
+
refs/status retain both values and byte order. Absence on the selected host
|
|
1683
|
+
is never re-anchor, status-clear, or delete authority.
|
|
650
1684
|
- Preflight rejects a missing/invalid root or Pane CWD, a zero-Window Project,
|
|
651
|
-
|
|
1685
|
+
an anchor ref that is neither an exact same-Window shell nor the owning
|
|
1686
|
+
Agent's current managed Pane, a live Window whose exact anchor is dead, and foreign,
|
|
652
1687
|
duplicate, wrong-owner, or ambiguous live claims before the first create.
|
|
653
1688
|
Execute rechecks the same plan under the Registry lock. A server-wide uid
|
|
654
1689
|
preflight runs first, *before* the selected Project session is created,
|
|
@@ -678,6 +1713,55 @@ Explicit Registry topology materialization:
|
|
|
678
1713
|
Only the selected exact socket is claimed and mutated; sibling sockets are
|
|
679
1714
|
tested unchanged, and no global uniqueness across unknown sockets is claimed.
|
|
680
1715
|
|
|
1716
|
+
Plan-only runtime mutation boundary:
|
|
1717
|
+
|
|
1718
|
+
- Lifecycle and topology changes owned by the app materializer and Pane-delete
|
|
1719
|
+
runtime are values before they are commands. The closed action inventory
|
|
1720
|
+
records a stable target, a typed guard with the exact expected evidence, a
|
|
1721
|
+
total order, expected effect, and typed executable operands; its JSON
|
|
1722
|
+
projection is deterministic. The argv seam rejects an operand target that
|
|
1723
|
+
does not match the printable stable target.
|
|
1724
|
+
Session/Window/Pane creation, identity and create-operation lease writes,
|
|
1725
|
+
layout writes, ownership-checked rollback, exact Pane kill, pre-commit
|
|
1726
|
+
tombstone/restore, and post-result-flush self-kill queueing all enter the same
|
|
1727
|
+
plan -> printable target/route guard -> effect reobserve/replan -> semantic
|
|
1728
|
+
guard -> execute -> effect reobserve/replan boundary. Before an already
|
|
1729
|
+
satisfied row may disappear, the executor binds its printed logical/physical
|
|
1730
|
+
socket and server-generation authority to the captured route; semantic
|
|
1731
|
+
pre-write guards still run together before the first live write.
|
|
1732
|
+
- Materialization is a sequence of dynamically replanned stages because exact
|
|
1733
|
+
Window and Pane handles do not exist until the preceding create effect is
|
|
1734
|
+
reobserved. Each stage is nevertheless a complete printable plan with a
|
|
1735
|
+
total order; the next stage is built only from the preceding stage's observed
|
|
1736
|
+
exact effect. Every production row carries `-L=<name>` or the exact
|
|
1737
|
+
`-S=<absolute path>`, the independently observed physical socket, and a
|
|
1738
|
+
printable route receipt. App-owned receipts pin `#{pid}` plus ownership and
|
|
1739
|
+
logical markers; inherited standalone receipts pin the exact server pid and
|
|
1740
|
+
originating `$N`/`@N`/`%N` containment while requiring both app markers
|
|
1741
|
+
blank. The public controller's explicit `--socket-path` grant is narrower:
|
|
1742
|
+
it prints the operator-selected path/PID blank-marker class and relies on
|
|
1743
|
+
each planned action's real UID plus session/window guards; it never infers an
|
|
1744
|
+
arbitrary Pane as invocation evidence. Generated popup/menu producers pass an exact Pane anchor which is
|
|
1745
|
+
reobserved on that same socket rather than trusting a targetless current
|
|
1746
|
+
Pane. Before a stage writes, the same `-S` runner refuses path, generation,
|
|
1747
|
+
class, or containment drift. Only a create-session
|
|
1748
|
+
stage may accept the typed no-server observation, because its explicit route
|
|
1749
|
+
and absent-session ownership preflight are the facts required to create the
|
|
1750
|
+
first server.
|
|
1751
|
+
- A guard refusal writes nothing and asks the caller to observe and plan again.
|
|
1752
|
+
Reobservation is explicit: known achieved effects remove their rows, so a
|
|
1753
|
+
successful repeat is an empty plan; an unavailable observation is unknown and
|
|
1754
|
+
can neither synthesize a Registry deletion nor authorize a runtime kill.
|
|
1755
|
+
Partial execution rolls back only actions carrying an ownership-backed undo,
|
|
1756
|
+
in reverse application order. Existing desired Registry state, foreign or
|
|
1757
|
+
sibling objects, and other sockets have no rollback authority.
|
|
1758
|
+
- Pane deletion keeps the exact routed socket plus Session, Window, Pane, root
|
|
1759
|
+
kind/root uid, and current Pane mirror in every executable guard. A
|
|
1760
|
+
caller-containing delete still commits the Registry and flushes the complete
|
|
1761
|
+
result before its self-target kill is queued. The AI picker/default/resume and
|
|
1762
|
+
shell split producers remain canonical create-intent producers; they do not
|
|
1763
|
+
gain a second tmux mutation path.
|
|
1764
|
+
|
|
681
1765
|
Agent runtime linkage:
|
|
682
1766
|
|
|
683
1767
|
- Once a live tmux pane has settled on a registry Pane, reconcile decides which
|
|
@@ -693,6 +1777,14 @@ Agent runtime linkage:
|
|
|
693
1777
|
Agent — Phase 1's refuse rule, unchanged. The legacy import path already
|
|
694
1778
|
trusted exactly this option to mint an Agent on its create path; linkage makes
|
|
695
1779
|
the adopt and rebind paths agree with it.
|
|
1780
|
+
- **The canonical default shell remains Registry-owned.** A generic
|
|
1781
|
+
`@projmux_ai_agent` marker on the direct Window-owned `role=shell` Pane named
|
|
1782
|
+
by `Window.spec.defaultShellPaneRef` is reported as reason-bearing D2 and
|
|
1783
|
+
performs no Agent mint, Pane reparent, or reservation move. Runtime metadata
|
|
1784
|
+
cannot invalidate the Registry's canonical shell chain. This exception is
|
|
1785
|
+
deliberately exact: an anchor-only shell that is not the default shell keeps
|
|
1786
|
+
the existing linkage behavior, whose promotion semantics belong to the
|
|
1787
|
+
separate anchor/primary-shell track.
|
|
696
1788
|
- **Which Agent, in order.** (1) The Pane is already Agent-owned: that Agent is
|
|
697
1789
|
the answer and only `status.paneRef` is repaired. (2) An Agent in the same
|
|
698
1790
|
Window already records the same provider conversation in `status.sessionRef`
|
|
@@ -741,6 +1833,48 @@ Agent runtime linkage:
|
|
|
741
1833
|
maintenance riding along inside somebody else's transaction: one pane it cannot
|
|
742
1834
|
register must not fail the `create` that happened to trigger it.
|
|
743
1835
|
|
|
1836
|
+
Resource-first create:
|
|
1837
|
+
|
|
1838
|
+
- **One parser, one product model.** `create window|pane|agent|<provider>` share
|
|
1839
|
+
a single argv surface and a single resource-backed implementation.
|
|
1840
|
+
`--project` is a scope flag, never a mode selector, so no flag chooses between
|
|
1841
|
+
two meanings of the same command. The runtime-only "split the current window"
|
|
1842
|
+
half that used to sit behind an absent `--project` is removed; a raw,
|
|
1843
|
+
unmanaged split is tmux's own verb, not a projmux resource verb.
|
|
1844
|
+
- **Scope resolution has exactly two branches.** An explicit `--project`/`-p`
|
|
1845
|
+
wins inside and outside tmux and suppresses the active-target read entirely.
|
|
1846
|
+
With no `--project`, the Project is derived from the active exact runtime
|
|
1847
|
+
through the same `@projmux_window_uid` mirror and registry `ownerRef` chain
|
|
1848
|
+
the read verbs use.
|
|
1849
|
+
- **Window and anchor follow the whole scope, not the Project flag.** They are
|
|
1850
|
+
derived only when the argv named no `--project`, `--window`, `--pane`, and no
|
|
1851
|
+
`--selector` at all. That keeps a bare `create pane --placement right` -- the
|
|
1852
|
+
generated keybinding body -- a split of the Window the operator is looking at,
|
|
1853
|
+
instead of a fan-out over every Window of the Project, while one explicit
|
|
1854
|
+
occurrence still fixes the whole target set. An explicit `--pane` or popup
|
|
1855
|
+
origin is the exact split anchor. Only a scope with no Pane consumes the
|
|
1856
|
+
target Window's role-agnostic `spec.anchorPaneRef`; a missing, stale, dead, or
|
|
1857
|
+
cross-Window ref refuses with no alternate-live-Pane inference.
|
|
1858
|
+
- **Refusals cost nothing.** Home, control, unattributed, foreign, a mirrored
|
|
1859
|
+
uid the Registry does not hold, a Window whose Project is gone, and every
|
|
1860
|
+
outside-tmux invocation with no `--project` are usage errors naming
|
|
1861
|
+
`--project`. They are raised before the registry transaction opens, so they
|
|
1862
|
+
are measurably zero Registry writes and zero tmux calls. Nothing falls back to
|
|
1863
|
+
a runtime-only split, nothing invents a Project from `$HOME`, a session name,
|
|
1864
|
+
or a cwd, and no default server is probed.
|
|
1865
|
+
- **Host neutrality is transport-level, not policy-level.** Inside an app-owned
|
|
1866
|
+
or a standalone server the create mutates only the inherited exact socket,
|
|
1867
|
+
because every tmux call it issues inherits `$TMUX` and it never enumerates
|
|
1868
|
+
siblings. Outside tmux an explicit Project is the gate before anything live is
|
|
1869
|
+
touched.
|
|
1870
|
+
- **Everything is detached.** No create path issues `switch-client`,
|
|
1871
|
+
`select-window`, `select-pane`, or `attach-session`. `focus pane` and
|
|
1872
|
+
`-o pane-id` are how a caller ends up in the new pane.
|
|
1873
|
+
- **Focus is navigation-only.** `focus project|window|pane` reads live tmux
|
|
1874
|
+
inventory and may move an existing client, but has no Registry store and
|
|
1875
|
+
issues no session/Window/Pane creation, identity-marker, rename, respawn, or
|
|
1876
|
+
deletion write. An offline target remains offline and exits unresolved.
|
|
1877
|
+
|
|
744
1878
|
Selector and the implicit active target:
|
|
745
1879
|
|
|
746
1880
|
- A selector value is either `uid:<uid>` or a `metadata.name`. There is no
|
|
@@ -752,26 +1886,49 @@ Selector and the implicit active target:
|
|
|
752
1886
|
- Inside tmux, an invocation of a **singular read or rename verb** that carries
|
|
753
1887
|
no selector at all resolves the **active tmux target**: `get pane`,
|
|
754
1888
|
`describe project|window|pane|agent`, `rename project|window|pane`, and
|
|
755
|
-
`rebind project`. Any reference, scope flag, or label keeps the
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1889
|
+
`rebind project`. Any reference, scope flag, or label keeps picking the target
|
|
1890
|
+
itself; the destructive routes are unaffected. `create` reads the same seam
|
|
1891
|
+
under its own rule, described below.
|
|
1892
|
+
- The plural registry reads `get windows|panes|agents` use the active Window's
|
|
1893
|
+
exact Registry owner as their default managed root. A Project-owned Window
|
|
1894
|
+
exposes only that Project's descendants; a ControlSession-owned Window (the
|
|
1895
|
+
Home control surface) exposes only that ControlSession's descendants. Name
|
|
1896
|
+
and label selectors continue to filter inside that default root. An explicit
|
|
1897
|
+
`uid:` selector is already opaque Registry-global authority, so it bypasses
|
|
1898
|
+
active-root observation and narrowing, including from a foreign tmux Pane.
|
|
1899
|
+
An in-tmux Window with no exact existing Project or ControlSession owner is a
|
|
1900
|
+
usage refusal with zero stdout only when no explicit Project, whole-set, or
|
|
1901
|
+
uid authority bypasses the default; it never silently falls back.
|
|
1902
|
+
- An **explicit singular reference** on `describe window|pane|agent` remains
|
|
1903
|
+
Project-namespaced. Generic `rename window|pane|agent` instead derives the
|
|
1904
|
+
exact Project or ControlSession that owns the active Window. When
|
|
1905
|
+
`--project` is absent inside a managed Project, both families derive the
|
|
1906
|
+
Project from the active Window uid mirror and Registry owner chain. This
|
|
1907
|
+
narrows the Window universe only; it never
|
|
1908
|
+
chooses one Window, Pane, or Agent for the operator, so a same-named pair
|
|
1909
|
+
inside the one Project stays the ordinary bounded exact-one ambiguity and a
|
|
1910
|
+
`uid:` reference outside the scope is a no-match rather than a cross-Project
|
|
1911
|
+
hit. The describe family remains the intentional Project-only difference;
|
|
1912
|
+
Phase 14 extends only the generic rename family to ControlSession.
|
|
1913
|
+
`get projects`, `describe|rename project`, `delete`, `rebind`, and `agent
|
|
1914
|
+
resume` are outside that reference scope, and notifications and snapshots
|
|
1915
|
+
belong to separate stores. Delete's exact live preflight nevertheless follows
|
|
1916
|
+
either root kind through the selected descendant's owner chain.
|
|
764
1917
|
- `--all-projects` is the explicit registry-wide escape for those three reads.
|
|
765
1918
|
It is deliberately different from destructive `delete --all`, whose existing
|
|
766
1919
|
whole-registry compatibility meaning is unchanged. A bare `--all` is not a
|
|
767
1920
|
read flag. Explicit `--project` keeps its prior result and cannot be combined
|
|
768
1921
|
with `--all-projects`.
|
|
769
|
-
- Outside tmux, an omitted
|
|
770
|
-
inventory
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1922
|
+
- Outside tmux, an omitted root scope keeps the historical whole-registry
|
|
1923
|
+
inventory and its ambiguity, for a plural read and for a reference alike.
|
|
1924
|
+
Inside tmux, a missing Window binding or broken managed-root owner chain is a
|
|
1925
|
+
usage refusal with zero stdout, never a silent global fallback. The selector
|
|
1926
|
+
engine's `windowScope` is the single choice point for explicit Project,
|
|
1927
|
+
active-derived Project/ControlSession root, or global scope, shared by Window,
|
|
1928
|
+
Pane, and Agent resolution. The plural default and the Project-only singular
|
|
1929
|
+
namespace both fill `Query.DefaultRoot`; only the former can carry a
|
|
1930
|
+
ControlSession kind. The default is consulted only after ruling out explicit
|
|
1931
|
+
`--project`, `--all-projects`/`-A`, and any applicable `uid:` occurrence.
|
|
775
1932
|
- There is **no sentinel value token**. `current` and `active` pass
|
|
776
1933
|
`ValidateName`, so `--pane current` would shadow a resource that legitimately
|
|
777
1934
|
carries that name. Omission is the only spelling. If an explicit one is ever
|
|
@@ -784,8 +1941,9 @@ Selector and the implicit active target:
|
|
|
784
1941
|
select a wrong target.
|
|
785
1942
|
- Only two options are read: `@projmux_pane_uid` on the active pane and
|
|
786
1943
|
`@projmux_window_uid` on its window (window-scoped options resolve through a
|
|
787
|
-
pane target). Every ancestor above them comes from `ownerRef` — the Project
|
|
788
|
-
the owner of the active Window, the Agent is the owner
|
|
1944
|
+
pane target). Every ancestor above them comes from `ownerRef` — the Project or
|
|
1945
|
+
ControlSession is the owner of the active Window, and the Agent is the owner
|
|
1946
|
+
of the active Pane.
|
|
789
1947
|
The session-scoped `@projmux_project_uid` is **not** consulted: it is
|
|
790
1948
|
measurably empty on live sessions, so trusting it would refuse targets the
|
|
791
1949
|
owner chain resolves.
|
|
@@ -800,7 +1958,10 @@ Selector and the implicit active target:
|
|
|
800
1958
|
a message naming what was inspected. It is deliberately not the
|
|
801
1959
|
`matched N ..., want exactly one` cardinality error, because an unmanaged pane
|
|
802
1960
|
carrying no `@projmux_pane_uid` is the common case and presenting it as
|
|
803
|
-
ambiguity would hide the cause.
|
|
1961
|
+
ambiguity would hide the cause. An undecidable *namespace* refuses with its
|
|
1962
|
+
own message rather than that one, because "no selector was given" is false on
|
|
1963
|
+
an invocation that carried a reference and would send the operator after the
|
|
1964
|
+
wrong cause.
|
|
804
1965
|
|
|
805
1966
|
## Naming metadata model
|
|
806
1967
|
|
|
@@ -908,6 +2069,13 @@ network call.
|
|
|
908
2069
|
- **Failure preservation** — adapter failures do not erase prior
|
|
909
2070
|
rows. The Manager merges new snapshots over the on-disk slice, so a
|
|
910
2071
|
transient 429 keeps the last known good numbers visible.
|
|
2072
|
+
- **Codex native source selection** — the Codex adapter alone owns one
|
|
2073
|
+
invocation's source decision. It normalizes native
|
|
2074
|
+
`account/rateLimits/read` plus bounded sparse update events into snapshots;
|
|
2075
|
+
only unavailable/unsupported/account-empty outcomes invoke the newest
|
|
2076
|
+
rollout parser once. Native and rollout rows are never synthesized together.
|
|
2077
|
+
Optional snapshot provenance preserves source, fallback/stale reason, and
|
|
2078
|
+
native bucket label/cadence through Store and all public read surfaces.
|
|
911
2079
|
|
|
912
2080
|
See [usage-tracking.md](usage-tracking.md) for adapter detail (token
|
|
913
2081
|
refresh, rollout schema).
|
|
@@ -942,6 +2110,39 @@ cache.
|
|
|
942
2110
|
|
|
943
2111
|
## Related design and inventory notes
|
|
944
2112
|
|
|
2113
|
+
### Plan-only managed runtime mutation
|
|
2114
|
+
|
|
2115
|
+
Managed lifecycle/topology changes are printable `runtimeMutationPlan` rows.
|
|
2116
|
+
Each row carries an exact invocation route, immutable observed socket path,
|
|
2117
|
+
printable server-generation authority, a stable tmux handle and Registry
|
|
2118
|
+
UID/owner chain, a closed guard, total order,
|
|
2119
|
+
expected effect, and printable typed operands bound to that handle. Execution
|
|
2120
|
+
validates printable target/route authority before pre-effect reobservation and
|
|
2121
|
+
every pending semantic guard before the first write;
|
|
2122
|
+
owned rollback runs in reverse order. Materialization is intentionally staged:
|
|
2123
|
+
after each dynamic handle is returned, it is reobserved and the next stage is
|
|
2124
|
+
planned, so no later action guesses a Window or Pane handle. A successful
|
|
2125
|
+
reobserve/replan is empty; an unknown observation authorizes no delete or kill.
|
|
2126
|
+
App-owned execution requires exact path/pid/app/logical evidence. An inherited
|
|
2127
|
+
standalone route is separately closed by exact `TMUX=path,pid,index` plus a
|
|
2128
|
+
producer-verified Pane receipt and prints/executes through `-S`; partial app
|
|
2129
|
+
markers never downgrade to standalone. Explicit controller reconciliation may
|
|
2130
|
+
instead use an operator-selected `--socket-path` plus PID/blank-marker receipt,
|
|
2131
|
+
but only action-specific UID and containment guards authorize its writes.
|
|
2132
|
+
Fresh app bootstrap is the only
|
|
2133
|
+
pre-server declaration without a generation receipt, and binds path/pid/$@%
|
|
2134
|
+
before its route marker and all later rows.
|
|
2135
|
+
|
|
2136
|
+
The maintained product table in `internal/app/runtime_mutation_surface.go` maps
|
|
2137
|
+
generated catalog/menu producers, native provider/resume picker selections,
|
|
2138
|
+
sidebar/session-picker stops, and app lifecycle entrypoints in both directions
|
|
2139
|
+
to their handler and plan verb. It also records exact semantic exemptions for
|
|
2140
|
+
focus, labels, operator-requested layout, mouse forwarding, snapshot replay,
|
|
2141
|
+
ephemeral maintenance, app quit, and human runtime maintenance. Managed argv
|
|
2142
|
+
verbs are selected only by the typed executor seam; generated Window
|
|
2143
|
+
create/rename, Pane-menu create/delete, and automatic post-split layout writes
|
|
2144
|
+
reach typed intent/operand routes rather than embedding tmux lifecycle commands.
|
|
2145
|
+
|
|
945
2146
|
Contributor-facing companions to this document. They are design records and
|
|
946
2147
|
inventories rather than user documentation, so they are linked from here rather
|
|
947
2148
|
than from the README docs index.
|