projmux 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,24 @@
1
1
  # Explicit reply recovery
2
2
 
3
3
  An explicit Claude reply names the original request with `--reply-to`. The
4
- broker preserves that request's `conversationRef`, reverses its exact source
5
- and target routes, and retains peer, untrusted, coordination-only authority.
6
- Both activations and the original deadline must still be current. A reply
7
- cannot extend that deadline. Source metadata remains an unverified routing
4
+ broker preserves that request's `conversationRef`, reverses its source and
5
+ target Agents, and retains peer, untrusted, coordination-only authority. The
6
+ reply goes to each Agent's current activation, and the original deadline must
7
+ still be current. A reply cannot extend that deadline.
8
+
9
+ Correlation follows the Agent and its provider conversation, not one
10
+ activation. Either Agent may have been relaunched into the same conversation
11
+ since the original, for example by `agent relaunch`: it now runs in a new Pane
12
+ under a new activation, and a reply to the earlier message still commits and
13
+ is delivered there. A reply to another Agent or on another provider is still
14
+ refused as `invalid-explicit-reply-correlation`. When one of the original's
15
+ Agents is now in another provider conversation (another Claude session), the
16
+ reply is refused with `explicit-reply-conversation-changed` and stores
17
+ nothing; the original cannot be answered there, so send a new message without
18
+ `--reply-to`. A `--dialogue-reply-only` Agent's reply tool follows the same
19
+ rule for the original's sender: it still permits a reply to a sender
20
+ relaunched into the same conversation, and denies one to a sender now in
21
+ another conversation. Source metadata remains an unverified routing
8
22
  claim; an explicit reply also requires the registered provider's descendant
9
23
  caller and any existing qualification and execution guard.
10
24
 
@@ -33,7 +47,7 @@ execution guard, use the existing bounded command without that flag:
33
47
  projmux agent message send uid:<original-source-agent> --reply-to <original-request-ref> -- '<corrected reply>'
34
48
  ```
35
49
 
36
- Operator input from the web client has no Agent route to reverse, so a reply
50
+ Operator input from an operator client has no Agent route to reverse, so a reply
37
51
  to it is refused with `explicit-reply-operator-origin` and stores nothing.
38
52
 
39
53
  Within the same provider session, a reply also commits after the Claude lease
@@ -41,10 +55,52 @@ helper was replaced, for example by compact. The new helper did not push the
41
55
  original, so it reads the original from the durable store and judges it by the
42
56
  same checks. It reads the store only while its own route is the Registry's
43
57
  current authority for the Agent; a replaced or unregistered helper reads and
44
- writes nothing. `broker-reply-original-not-found` means the original is in
45
- neither the helper nor the store. `invalid-explicit-reply-correlation` means the
46
- reply's route or conversation does not match the original, or the helper is not
47
- the current one.
58
+ writes nothing, and is refused with `broker-reply-helper-not-current`.
59
+ `broker-reply-original-not-found` means the original is in neither the helper
60
+ nor the store.
61
+
62
+ ## Reply refusal tokens
63
+
64
+ Each refusal names its cause with one token. The CLI prints it after
65
+ `replyTo=<ref>:`, the Claude helper returns it as the `reply-refused` reason,
66
+ and the store returns it as the reply conflict reason. A token asks for one
67
+ action, so two causes that need different actions never share one.
68
+ `invalid-explicit-reply-correlation` is kept for a reply that does not match
69
+ its original. Every refusal here except `broker-reply-outcome-unknown` comes
70
+ before the reply is written anywhere: no provider saw it, so following the
71
+ action cannot duplicate it. The retry rules below govern an attempt that was
72
+ stored.
73
+
74
+ | Token | Cause | Action |
75
+ | --- | --- | --- |
76
+ | `invalid-explicit-reply-correlation` | The reply's Agents, providers, or `conversationRef` do not match the original. | Check that `--reply-to` names a message you received and that the positional Agent is its sender. |
77
+ | `explicit-reply-conversation-changed` | One of the original's Agents is now in another provider conversation. | Send a new message without `--reply-to`. |
78
+ | `invalid-explicit-reply-envelope` | The reply itself is not a valid envelope, for example a payload over the limit. | Correct the reply, for example shorten it, and send it with a fresh ref. |
79
+ | `explicit-reply-operator-origin` | The original is operator input from an operator client. | There is no Agent to answer; do not reply to it. |
80
+ | `explicit-reply-deadline-expired` | The original's deadline has passed. | Send a new message without `--reply-to`. |
81
+ | `explicit-reply-deadline-extended` | The reply's deadline is later than the original's. | Send the reply without a longer `--ttl`. |
82
+ | `explicit-reply-source-route-stale`, `explicit-reply-target-route-stale` | The replying or the answered Agent's route is not its current one, for example during a relaunch, or the helper serving the reply is not the replying Agent's. | Wait until the Agent is registered again, then send the reply. |
83
+ | `explicit-reply-route-stale` | The helper found a route no longer current at the moment it committed. | As above. |
84
+ | `broker-reply-original-not-found` | The original is in neither the helper nor the store, for example because it was reclaimed. | Send a new message without `--reply-to`. |
85
+ | `broker-reply-original-not-delivered` | The original never reached its target, so there is nothing to answer. | Inspect the original with `agent message status`; do not reply to it. |
86
+ | `broker-reply-original-without-envelope` | The helper holds the ref, but not as an Agent message it can answer. | Send a new message without `--reply-to`. |
87
+ | `broker-reply-helper-closed` | The replying Agent's Claude helper is shutting down, for example for a restart. | Wait for the new helper to register, then send the reply. |
88
+ | `broker-reply-helper-not-current` | The Registry no longer names this helper as the Agent's current one; it was replaced. | Wait for the current helper to register, then send the reply. |
89
+ | `broker-reply-unavailable` | The helper runs without a message broker and can commit no reply. | Relaunch the Agent so it starts a helper with one. |
90
+ | `broker-reply-store-unavailable` | The helper could not use the durable message store. | Check the state directory, then send the reply. |
91
+ | `broker-reply-store-busy` | Another writer held the message store. | Send the reply again. |
92
+ | `broker-reply-store-malformed` | The message store file could not be read as a store. | Inspect the store; do not resend until it reads. |
93
+ | `broker-reply-store-capacity` | The store is full and nothing can be reclaimed; see below. | Wait for records to be reclaimed. |
94
+ | `reply-already-committed` | Another reply to the original was already committed. | Inspect it with `previousRef`; do not resend. |
95
+ | `reply-ref-envelope-mismatch` | The same reply ref was used before with different content. | Use a fresh ref. |
96
+ | `broker-reply-outcome-unknown` | A commit was attempted and its result is not known. | Inspect the reply status; do not resend. |
97
+
98
+ A Claude helper that started before a build keeps that build's tokens until it
99
+ is replaced. An older helper therefore still reports most of these causes as
100
+ `invalid-explicit-reply-correlation`, and the CLI prints whichever token the
101
+ helper sent.
102
+
103
+ ## Retries
48
104
 
49
105
  A same-ref call returns the original immutable receipt and never pushes again.
50
106
  Changing its payload is refused with the earlier ref and cause. A fresh ref
@@ -92,7 +148,7 @@ Each line carries the envelope's own key names:
92
148
  {"schemaVersion":1,"evictedAt":"…","reason":"retention","adapter":"claude-coordination",
93
149
  "messageRef":"…","conversationRef":"…","replyTo":"…",
94
150
  "state":"delivered","deliveryReason":"unspecified","handoffObserved":false,
95
- "acceptedAt":"…","terminalAt":"…","payloadBytes":466,
151
+ "acceptedAt":"…","terminalAt":"…","payloadBytes":466,"payloadSHA256":"…",
96
152
  "source":{"agentUID":"…","provider":"…"},
97
153
  "target":{"agentUID":"…","provider":"…"}}
98
154
  ```
@@ -105,14 +161,17 @@ the Pane and activation generation fence a live delivery, and the incarnation
105
161
  follows the provider conversation; all three mean nothing once the record has
106
162
  left the store. The envelope's `deadline` is not written. A line for operator
107
163
  input (see [Operator input](claude-coordination-endpoints.md#operator-input)) carries
108
- `"origin":{"kind":"operator","client":"web"}` in place of `source`; an Agent
164
+ `"origin":{"kind":"operator","client":"<client>"}` in place of `source`; an Agent
109
165
  message's line has no `origin`. Every other key is always present.
110
166
 
111
167
  Lines written by earlier builds may still be in the same file. They carry full
112
168
  routes (`paneUID`, `activationGeneration`, `incarnation`), a `deadline`, and
113
169
  `outcomeUnknown` even when it is `false`, and they read the same way: a reader
114
170
  ignores keys it does not expect and reads an absent key as its zero value, so
115
- both shapes give the same edges, states and times. Both are `schemaVersion` 1.
171
+ both shapes give the same edges, states and times. Lines written before the
172
+ payload digest existed have no `payloadSHA256` key and read with an empty
173
+ digest, which means "not recorded", not a mismatch; earlier lines are not
174
+ backfilled. All of these are `schemaVersion` 1: every added key is additive.
116
175
 
117
176
  `schemaVersion` starts at 1 and follows the same rule as the
118
177
  coordination frame's field of that name: an absent or zero value reads as 1, and
@@ -121,8 +180,12 @@ discarding the line or failing. It is independent of the durable envelope
121
180
  version and of the store file version.
122
181
 
123
182
  The log does not keep the message body. It records `payloadBytes`, the original
124
- payload length, and has no `payload` key at all: the log is unbounded in time,
125
- and its intended consumers need the message graph, not its text.
183
+ payload length, and `payloadSHA256`, the SHA-256 of the payload's exact bytes
184
+ as 64 lowercase hex characters, and has no `payload` key at all: the log is
185
+ unbounded in time, and its intended consumers need the message graph, not its
186
+ text. The digest lets a consumer that holds the text elsewhere, such as a
187
+ transcript, prove it is the same message; the reader and its consumers compute
188
+ it with the same function (`PayloadSHA256` in `internal/core/agentmessage`).
126
189
 
127
190
  The active log rotates to `history.jsonl.1` once it would pass 8 MiB, replacing
128
191
  any earlier `history.jsonl.1`. At most two generations exist, so the log's disk
@@ -418,27 +418,51 @@ Identity and naming:
418
418
  context may duplicate and is never a selector, reservation, ownerRef, or
419
419
  durable identity input. `metadata.labels` remains key/value classification;
420
420
  `metadata.annotations` remains non-identifying metadata such as an AI topic.
421
- - Creator provenance: when an explicit `create agent` (every spelling,
422
- `--create-window`, and each Agent of a fan-out) or `create window --provider`
423
- runs inside an Agent's managed Pane, the new Agent and its managed Pane carry
424
- `projmux.io/creator-agent` (the creator Agent's bare UID),
425
- `projmux.io/creator-pane` (that Agent's managed Pane's bare UID), and
426
- `projmux.io/creator-basis: pane-chain`, written in the same transaction that
427
- commits the Agent. They are recorded only when the unmasked ambient
428
- `%N` (`__PROJMUX_RUNTIME_ANCHOR_PANE`, then `TMUX_PANE`) is exactly one live
429
- Registry Pane, that Pane round-trips with its owning Agent's
430
- `status.paneRef`, one `display-message` confirms it on the create's own
431
- app-owned socket and server pid, and the create process descends from its
432
- `#{pane_pid}`. Any failed check writes none of the keys and changes nothing
433
- else about the create. Stderr gets one `creator not recorded: <reason>` line
434
- only when the ambient Pane is a live Agent Pane but a later check fails; an
421
+ - Creator provenance: every Agent create records on the new Agent and its
422
+ managed Pane, in the same transaction that commits the Agent, exactly one of
423
+ these, decided once per create (a fan-out records the same one on every
424
+ Agent). `projmux.io/creator-basis` names the kind of evidence, and only the
425
+ keys that evidence proves are written:
426
+ 1. `pane-chain`, with `projmux.io/creator-agent` (the creator Agent's bare
427
+ UID) and `projmux.io/creator-pane` (that Agent's managed Pane's bare
428
+ UID): an explicit `create agent` (every spelling, `--create-window`, and
429
+ each Agent of a fan-out) or `create window --provider` ran inside an
430
+ Agent's managed Pane. It is recorded only when the unmasked ambient `%N`
431
+ (`__PROJMUX_RUNTIME_ANCHOR_PANE`, then `TMUX_PANE`) is exactly one live
432
+ Registry Pane, that Pane round-trips with its owning Agent's
433
+ `status.paneRef`, one `display-message` confirms it on the create's own
434
+ app-owned socket and server pid, and the create process descends from its
435
+ `#{pane_pid}`. It always wins.
436
+ 2. `explicit`, with `projmux.io/creator-agent` only: the caller declared
437
+ `--creator uid:<agent>` and no pane chain was recorded. The declaration
438
+ must name an Agent in the Registry, or the create is refused before
439
+ anything changes. A declaration that disagrees with a recorded pane chain
440
+ is dropped, and stderr gets one `creator declaration not recorded:
441
+ pane-chain-disagrees (--creator uid:<agent>)` line.
442
+ 3. `operator`, with `projmux.io/creator-client` only: the create ran in
443
+ process for a named operator client. The UI intent creates (picker, pane
444
+ menu, `ai split`, launch choice, the new-Window key) record the client
445
+ `ui`; a client layered on projmux that runs creates in process uses the
446
+ `recordOperatorCreator` seam. Such a create observes no pane chain,
447
+ because its process environment says nothing about who asked. A
448
+ `--creator` on it is dropped with the token `operator-client`. There is no
449
+ argv spelling of this basis; `TestNoArgvPathBuildsAnOperatorCreator`
450
+ holds its producers. Client names follow the one rule in
451
+ `internal/core/operatorclient`: 1-32 bytes of lowercase ASCII letters,
452
+ digits, and `-`, starting with a letter.
453
+ 4. Nothing, when there is no evidence.
454
+
455
+ A failed pane-chain check changes nothing else about the create. When
456
+ nothing is recorded, stderr gets one `creator not recorded: <reason>` line
457
+ only if the ambient Pane is a live Agent Pane but a later check fails; an
435
458
  ambient Pane that is malformed, unregistered, or a Window-owned shell is
436
- silent. These keys are provenance, not
437
- authentication, like a message `--source`. An absent key does not mean a
438
- human created the Agent: UI intent creates (picker, pane menu, `ai split`,
439
- launch choice) and creates a Codex Agent issues (its
440
- commands run under the app-server, not below the Pane's process) leave them
441
- empty, and nothing backfills older Agents.
459
+ silent. The creator Agent is never the created Agent. These keys are
460
+ provenance, not authentication, like a message `--source`: no permission,
461
+ route, or selector reads them, and `explicit` can be forged, which is what
462
+ its basis says. An absent key does not mean a human created the Agent: a
463
+ Codex Agent's own commands run under the app-server, not below the Pane's
464
+ process, so they record nothing unless they declare `--creator`. Nothing
465
+ backfills older Agents.
442
466
 
443
467
  Root lifecycle:
444
468
 
@@ -532,6 +556,62 @@ Termination evidence transport:
532
556
  receipt is the post-delete diagnostic: source, classification, observed time,
533
557
  Pane/Agent uid, generation, and wait status only. No command, pane content,
534
558
  prompt, transcript, or provider payload is recorded.
559
+ - Deletion records: every explicit deletion that commits its Registry
560
+ transaction appends exactly one JSON line to
561
+ `<state>/deletion-records.jsonl`, next to `termination-receipts.jsonl` and the
562
+ Registry it changed (`${XDG_STATE_HOME:-$HOME/.local/state}/projmux` by default). The writers are
563
+ `delete window|pane|agent`, `unregister project` and its deprecated alias
564
+ `delete project`, and `prune agent|project --yes`. The line is
565
+ `{"schemaVersion":1,"at","operation","operationID","via","actor":{"agentUID","paneUID","basis"},"targets":[{"kind","uid","name"}],"affected":[{"kind","uid","name","action"}]}`,
566
+ where `at` is RFC 3339 UTC; `operation` is one of `delete-window`,
567
+ `delete-pane`, `delete-agent`, `unregister-project`, `delete-project`,
568
+ `prune-agent`, `prune-project` (the record's own spelling; the receipt line a
569
+ delete prints spells it `delete.pane` and so on, a separate surface that is
570
+ not unified with it); `operationID` is the delete's intentional termination
571
+ receipt id, or one minted the same way for unregister and prune;
572
+ and `via` is `cli`, `ui` (the generated Pane and Window delete keys), or
573
+ `prune`. `targets` are what the operator named; `affected` is every
574
+ Project, ControlSession, Window, Agent, and Pane present before the commit
575
+ and absent after it (targets included), ordered by kind and then uid, each
576
+ with action `deleted`. No explicit deletion route removes a ControlSession
577
+ today (only a rolled-back transaction does, and that writes no record), so
578
+ that kind is listed for completeness of the two root kinds.
579
+ - The actor is the same pane-chain judgment creator provenance uses, run
580
+ against the pre-commit Registry inside the delete's own transaction, so a
581
+ delete that removes its own Agent still names it. `basis` is `pane-chain`
582
+ when it succeeds, creation's skip token otherwise (`anchor-invalid`,
583
+ `anchor-pane-unregistered`, `anchor-pane-ambiguous`, `caller-pane-not-agent`,
584
+ `pane-ref-mismatch`, `anchor-server-unproven`, `anchor-query-failed`,
585
+ `anchor-server-mismatch`, `anchor-pane-mismatch`,
586
+ `process-chain-unobservable`, `not-pane-descendant`), and empty when there
587
+ was no ambient Pane. The server step is the delete's own: one
588
+ `display-message -t %N` through the route the delete addresses must answer
589
+ with the `$TMUX` socket and server pid, the same `%N`, and the Registry
590
+ Pane's mirrored uid. `prune` has no socket flags and judges on the inherited
591
+ `$TMUX`; `unregister project` judges on its `--socket`/`--socket-path` or
592
+ `$TMUX`. A `ui` deletion is never judged and records an empty actor.
593
+ - A deletion a detached daemon runs (a Codex app-server command, say) is never
594
+ recorded as an Agent's. With no ambient Pane in its environment the actor is
595
+ empty with an empty basis; with an Agent Pane's inherited `TMUX`/`TMUX_PANE`
596
+ but a process that does not descend from that Pane, `basis` is
597
+ `not-pane-descendant` and both uids are empty.
598
+ - **An empty actor does not mean a human ran the deletion.** The record is
599
+ provenance, not authentication.
600
+ - The file is appended with one framed `O_APPEND` write and `fsync`, like the
601
+ termination journal, and is never truncated, rotated, or read back by
602
+ projmux. It is written after the commit and before the result is printed or
603
+ a self-targeted kill is queued. A record that cannot be written never fails
604
+ or changes the delete: stdout and the exit code are unchanged and stderr gets
605
+ exactly one `deletion not recorded: <token>` line, where token is
606
+ `state-dir-unavailable`, `append-failed`, or `operation-id-unavailable`.
607
+ `--dry-run`, a refusal, a declined confirmation, and a failed commit write
608
+ nothing.
609
+ - Non-guarantees: automatic lifecycle teardown (exit reconciliation), internal
610
+ row cleanup (resume handoff, topology replay, initial shell retirement,
611
+ rollback, Project fresh start), and the supervisor's process-exit path write
612
+ no deletion record. A delete issued by a Codex Agent's command runs under the
613
+ app-server rather than below the Pane's process and records
614
+ `not-pane-descendant`. There is no retention policy and no reader CLI.
535
615
  - The supervisor resolves its state paths from the pane's own inherited
536
616
  environment, which is the tmux **server's** environment rather than the
537
617
  environment of the CLI call that created the pane. That is the correct
@@ -663,9 +743,21 @@ Agent provider session ref:
663
743
  - Codex's turn id is deliberately **not** stored. A turn addresses one turn
664
744
  inside the conversation and changes on every hook event, so it is not a
665
745
  pointer to the conversation.
666
- - Transcript **paths** are recorded as the hook reported them. Nothing reads
667
- provider config files or transcript **contents**; that is permanently out of
668
- scope.
746
+ - Transcript **paths** are recorded as the hook reported them, and no
747
+ transcript content is read to fill the ref. The Registry, hook ingest, and
748
+ Agent resume paths (`agent resume`, `agent relaunch`, and the topology replay
749
+ below) never read provider config files, and they read transcript
750
+ **contents** only in these documented places:
751
+ - The three bounded tail readers that
752
+ [hooks.md](hooks.md#claude-code-hook-ingest) lists and scopes: `Stop` hook
753
+ ingest, the held coordination message release, and the Agent's pane
754
+ supervisor. Each reads at most the last 256 KiB of one Claude transcript,
755
+ and none writes what it read to the Registry. `Stop` keeps the last
756
+ assistant text as its notification row's text; hooks.md says where that
757
+ text goes.
758
+ - The explicit, user-run `agent sessions backfill` (see "Agent session
759
+ history (Claude)" below), which reads Claude transcript contents read-only
760
+ and only to attribute past sessions from delivered coordination frames.
669
761
  - The field is additive inside `schemaVersion: 1`. It is an optional pointer
670
762
  with `omitempty`, so a registry written before it existed decodes with a nil
671
763
  ref, validates, and re-encodes byte-identically. Bumping the envelope would
@@ -687,6 +779,214 @@ Agent provider session ref:
687
779
  hook whose provider contradicts the Agent's `spec.provider` is refused with
688
780
  zero mutations.
689
781
 
782
+ Agent session history (Claude and Codex):
783
+
784
+ - `status.sessionRef` holds one conversation and is overwritten when an Agent
785
+ moves to another one. The conversations a Claude or Codex Agent left are kept in
786
+ an append-only file outside the Registry,
787
+ `<state>/agent-session-history.jsonl` (next to `deletion-records.jsonl`;
788
+ `${XDG_STATE_HOME:-$HOME/.local/state}/projmux` by default). The Registry
789
+ schema and its version are unchanged.
790
+ - One line is appended each time a committed Registry write binds or replaces
791
+ a Claude session id or Codex thread id. Re-observing the same conversation,
792
+ including a same-thread Codex resume or endpoint handover, appends nothing.
793
+ The writers are a closed set of four functions: `persistAgentSessionRef`
794
+ (hook ingest) and `persistManagedAgentInteractionWithActivationPolicy`
795
+ (managed-Agent interaction commit) in `internal/app/agent_session_ref.go`,
796
+ `openIntentAgent` (resume-picker create and intent native Codex create) in
797
+ `internal/app/create_intent.go`, and `createAgent` (native Codex fresh
798
+ create) in `internal/app/create_agent.go`.
799
+ `TestClaudeSessionRefWritersRecordHistory` pins the callers of
800
+ `RecordAgentSessionRef`, and
801
+ `TestSessionHistoryObservedRowWritersCarryAffiliation` pins the set of four,
802
+ that each builds its row with `sessionhistory.ObservedRecordFor` (directly or
803
+ through `claudeSessionHistoryRecord`) from its transaction's `working`
804
+ Registry, that each reaches a post-commit append helper, and that no other
805
+ code builds an `observed` row with `RecordFor`. The append runs after the
806
+ commit and never fails its caller: a hook logs one `session-history` line to
807
+ `ai-ingest.log`, a create prints one
808
+ `agent session history not recorded: append-failed` line on stderr.
809
+ - Each line is `{"agentUID","provider","sessionId","transcriptPath","observedAt","source"}`:
810
+ `provider` is `claude` or `codex`, `observedAt` is the ref's RFC 3339 UTC observation
811
+ time, and `transcriptPath` is the path the Claude hook reported (empty for
812
+ Codex; never read by the
813
+ writers or by `agent sessions list`). `source` is `observed` for every line
814
+ a writer appends; the read side adds `current` for the Registry's ref; the
815
+ only producer of `estimated` is `agent sessions backfill` (below), whose
816
+ lines add one key, `lastRecordAt`. Lines are one framed `O_APPEND` write under an
817
+ exclusive `flock`, then `fsync`; the file is `0600` in a `0700` directory.
818
+ Readers skip and count an unparsable line.
819
+ - An `observed` line also carries the Agent's affiliation in three trailing
820
+ keys, `projectUID`, `windowUID`, and `agentName`, so a session stays
821
+ attributable after its Agent is deleted from the Registry. The writer
822
+ resolves them inside the same transaction, from that transaction's working
823
+ Registry (`sessionhistory.ResolveAffiliation`), link by link: `agentName`
824
+ when the Agent exists, `windowUID` when its ownerRef is an existing Window,
825
+ `projectUID` when that Window's ownerRef is an existing Project. A key whose
826
+ link does not resolve is omitted; nothing is guessed, and a chain that does
827
+ not resolve never withholds the line or fails the writer. `current` and
828
+ `estimated` rows carry none of the three. When `agent sessions list` merges
829
+ a conversation seen more than once, each of the three is the non-empty value
830
+ of the latest row that carries one.
831
+ - A line `agent sessions attribute` (below) writes adds a fourth key after
832
+ them, `affiliationBasis: "registry"`: its affiliation is the one the current
833
+ Registry gave the Agent when the user ran it, not its writer's transaction.
834
+ No other writer sets the key; an `observed` line without it is recorded at
835
+ write time. Merging carries `affiliationBasis` with `projectUID`: it is the
836
+ basis of the row that supplied the kept `projectUID`.
837
+ - `projmux agent sessions list <agent-ref> [-o json]` and the Go read function
838
+ `sessionhistory.List(stateDir, agent)` return the same rows: history joined
839
+ with the Registry's current ref, one row per `(agentUID, provider, sessionId)` in
840
+ `observedAt` order. A conversation seen more than once keeps its latest
841
+ observation time and transcript path, and is `current` when the Registry
842
+ names it; otherwise it is `observed` if any of its rows is, and `estimated`
843
+ only when every row is, so a backfilled estimate never hides an observation.
844
+ The JSON envelope adds `agentName` and `corruptLines`.
845
+ - `projmux agent sessions project <project-ref> [-o json]` and the Go read
846
+ function `sessionhistory.ListProject(stateDir, registry, projectUID)` list
847
+ the sessions attributed to one exact Project (resolved like any Project
848
+ ref). It is read-only. A Project no longer in the Registry is queried by its
849
+ exact `uid:<uid>` ref, where `<uid>` has exactly the shape projmux mints for
850
+ a Project: the listing then comes from the history rows alone,
851
+ `projectName` is `""`, and the table names the Project `project/uid:<uid>`.
852
+ A name ref, or a `uid:` ref of another kind or shape, resolves against the
853
+ Registry only and fails as before when it matches nothing.
854
+ - Input: every `claude` or `codex` line of the history file, plus one
855
+ `current` row for each Registry Agent with a supported provider.
856
+ - Attribution, per row: a row with a `projectUID` is attributed with its
857
+ own `projectUID`, `windowUID`, and `agentName`, never re-resolved, with
858
+ basis `registry` when its `affiliationBasis` is `registry` (it holds after
859
+ the Agent is deleted, with `inRegistry: false`) and `recorded` otherwise;
860
+ otherwise, a row whose Agent is in the current Registry with a complete
861
+ Agent -> Window -> Project chain is attributed with basis `registry`,
862
+ using the current values; any other row is unattributed. Rows are never
863
+ attributed from deletion records, Registry backups, a transcript's folder
864
+ or cwd, or time proximity.
865
+ - A session is `(provider, sessionId)`. A session none of whose rows is
866
+ attributed counts in `unattributed`; one whose attributed rows name two or
867
+ more Projects counts in `ambiguous` and is listed under none; one naming
868
+ exactly the requested Project is listed. Both counts, and `corruptLines`,
869
+ are over the whole history, not per Project.
870
+ - Each listed session has `provider`, `sessionId`, `transcriptPath`,
871
+ `observedAt`, `lastRecordAt` (when any row has one), and `source`, merged
872
+ over its attributed rows with the `agent sessions list` rules, and
873
+ `agents`: one entry per distinct Agent of those rows, in first-appearance
874
+ order (history file order, then the current rows), with `agentUID`,
875
+ `agentName`, `windowUID`, `inRegistry` (the Agent exists in the current
876
+ Registry), and `basis` (`recorded` wins when an Agent has both). Sessions
877
+ are ordered by `observedAt`.
878
+ - JSON: `{"projectUID","projectName","sessions","unattributed","ambiguous","corruptLines"}`;
879
+ `sessions` is `[]` when empty. The table prints
880
+ `project/<name> has no recorded sessions` when empty, marks an Agent no
881
+ longer in the Registry `(deleted)`, and prints one stderr line for each
882
+ non-zero `corruptLines`, `unattributed`, and `ambiguous` count.
883
+ - `projmux agent sessions backfill [--dry-run] [-o json]` recovers a subset of
884
+ the conversations from before this history existed. It is the one explicit,
885
+ user-run reader of transcript contents. Apart from the bounded tail readers
886
+ that [hooks.md](hooks.md#claude-code-hook-ingest) lists (`Stop` hook ingest,
887
+ the held coordination message release, and the pane supervisor), the
888
+ Registry, hook ingest, Agent resume, and `agent sessions list` never read a
889
+ transcript, and none of those tail readers adds a row to this history.
890
+ - Input: the top-level Claude transcripts
891
+ `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/<project>/<session>.jsonl`
892
+ (a blank `CLAUDE_CONFIG_DIR` is unset). Only regular files exactly one
893
+ directory deep are scanned: symlinks, directories, and deeper files such
894
+ as `<session>/subagents/*.jsonl` are not. Files are opened read-only and
895
+ never written; a missing projects directory scans nothing.
896
+ - Attribution: only `type: "user"` records that are not `isSidechain: true`
897
+ count, and within them only a string `message.content` or its
898
+ `type: "text"` blocks; tool results, assistant records, attachments, and
899
+ queue operations can quote a frame and are ignored. At each occurrence of
900
+ `{"kind":"projmux-coordination"` in that text, one JSON value is decoded;
901
+ a frame with that `kind` and a non-empty `target.agentUID` names its
902
+ target, and an occurrence that does not decode is skipped and counted.
903
+ A transcript naming exactly one distinct target Agent is attributed to
904
+ it; none is `noFrame`, two or more is `ambiguous`, and neither is ever
905
+ resolved from the folder name, the working directory, or time proximity.
906
+ - Meaning of `estimated`: the session received a delivered projmux message
907
+ addressed to that Agent. It is a subset of past sessions -- a session that
908
+ never received a message is not recovered -- and an attribution, not an
909
+ observation. `sessionId` is the file name, `transcriptPath` the absolute
910
+ file path, `observedAt` the transcript's first record `timestamp`, and
911
+ `lastRecordAt` its last one (both over every parseable record, RFC 3339
912
+ UTC). A candidate with no parseable timestamp is `unreadable`.
913
+ - Precedence and idempotency: a session already `observed` in the history
914
+ or `current` in the Registry for any Agent is `skippedObserved`; one
915
+ already `estimated` is `alreadyEstimated`; only the rest are appended, as
916
+ framed lines like every other writer's. The run holds the history file's
917
+ exclusive `flock` across the read, the check, and the append (not the
918
+ transcript scan), so concurrent runs cannot add a session twice and a
919
+ second run appends nothing.
920
+ - Report: `scanned`, `attributed`, `alreadyEstimated`, `skippedObserved`,
921
+ `ambiguous`, `noFrame`, `unreadable` (each transcript lands in exactly one
922
+ of these six), `malformedLines` (transcript lines that are not JSON
923
+ objects, skipped), `malformedFrames`, plus `dryRun`, `projectsDir`,
924
+ `historyPath`, and `rows` (the attributed rows; `[]` when none).
925
+ `--dry-run` reports exactly what a real run would append and writes
926
+ nothing, not even the state directory.
927
+ - Removing the estimated rows: with no projmux command running that could
928
+ write the history, keep every other line and swap the file in atomically
929
+ (the blank lines of the framed format are simply dropped):
930
+
931
+ ```sh
932
+ h="${XDG_STATE_HOME:-$HOME/.local/state}/projmux/agent-session-history.jsonl"
933
+ jq -c 'select(.source != "estimated")' "$h" > "$h.tmp" && chmod 600 "$h.tmp" && mv "$h.tmp" "$h"
934
+ ```
935
+
936
+ A line `jq` cannot parse stops it with an error before the `mv`; readers
937
+ already skip such lines, so remove or repair it first.
938
+ - `projmux agent sessions attribute [--dry-run] [-o json]` persists what the
939
+ current Registry proves about the conversations whose history carries no
940
+ Project, so `agent sessions project` keeps listing them after their Agent
941
+ is deleted. It reads only the history file and the current Registry: never
942
+ deletion records, Registry backups, or transcripts. It persists only what
943
+ the current Registry proves, and never restores an Agent that is no longer
944
+ in it.
945
+ - Input: every `(agentUID, provider, sessionId)` of the file's `claude` and
946
+ `codex` lines, in file order, then each Registry Agent's current ref
947
+ (supported providers only) whose key the file does not name.
948
+ - Precedence: a key with a line that has a `projectUID` is
949
+ `alreadyAttributed`; a key whose Agent is not in the Registry, or whose
950
+ Agent -> Window -> Project chain does not resolve, is `unresolved`; both
951
+ are left untouched. Every other key is `attributed`: one line is appended
952
+ for it, with `observedAt`, `transcriptPath`, and `lastRecordAt` merged
953
+ over its lines and its current ref with the `agent sessions list` rules;
954
+ `source` the highest-ranked source of its lines, or `observed` for a key
955
+ only the Registry names (`current` is never written); `projectUID`,
956
+ `windowUID`, and `agentName` from the current Registry; and
957
+ `affiliationBasis: "registry"`.
958
+ - Report: `scanned` (`attributed` + `alreadyAttributed` + `unresolved`),
959
+ `attributed`, `alreadyAttributed`, `unresolved`, `corruptLines`, plus
960
+ `dryRun`, `historyPath`, and `rows` (the attributed lines; `[]` when
961
+ none).
962
+ - Idempotency and locking: like backfill, a run holds the history file's
963
+ exclusive `flock` across the read, the classification, and one write of
964
+ every framed line, and releases it before the `fsync`. A second run
965
+ appends nothing, because every key it attributed now has a `projectUID`.
966
+ Existing lines are never rewritten. A run with nothing to append creates
967
+ nothing, and `--dry-run` reads without the lock, writes nothing, and
968
+ reports exactly what a real run would append.
969
+ - Undoing it, the same way as removing estimated rows:
970
+
971
+ ```sh
972
+ h="${XDG_STATE_HOME:-$HOME/.local/state}/projmux/agent-session-history.jsonl"
973
+ jq -c 'select(.affiliationBasis != "registry")' "$h" > "$h.tmp" && chmod 600 "$h.tmp" && mv "$h.tmp" "$h"
974
+ ```
975
+ - Non-guarantees: no session end time is recorded for observed rows;
976
+ conversations from before this history existed appear only as the `current`
977
+ row unless `agent sessions backfill` attributes them; Claude and Codex
978
+ conversation changes are recorded, Antigravity's are not; a line edited or
979
+ deleted by hand is not recovered; the history is kept after the Agent is deleted; the Registry
980
+ commit and the append are not atomic, so a crash between them loses that one
981
+ line. There is no retention policy. Lines written before the affiliation
982
+ keys existed carry none, so once their Agent is deleted they count only in
983
+ `unattributed` unless `agent sessions attribute` persisted their
984
+ affiliation while the Agent was still in the Registry; `agentName` is a
985
+ write-time snapshot and does not follow a later rename; a deleted Project
986
+ can no longer be named by name, only by its exact `uid:` ref, and then only
987
+ the history rows that carry its `projectUID` are listed; `agent sessions
988
+ project` computes no token totals.
989
+
690
990
  Agent launch argv (workspace / task boundary):
691
991
 
692
992
  - One Agent launch hands the provider CLI two independent things in a single
@@ -1040,6 +1340,10 @@ Resolved resource graph (`internal/core/resourcegraph`):
1040
1340
  first and the inherited `$TMUX` socket path second. There is no implicit
1041
1341
  default-server probe: with no transport the graph is a Registry-only snapshot
1042
1342
  whose runtime answers are all `unknown`, and a sibling socket is never read.
1343
+ The Registry views `get` and `describe` add one rung after `$TMUX`: they
1344
+ have no socket flag, so outside tmux they observe the app socket
1345
+ `-L projmux` (see *Runtime observation and resource status*). That is still
1346
+ one exact server, never the default one.
1043
1347
  - **Bounded and pure.** One observation costs one option probe plus three list
1044
1348
  queries whatever the size of the server, is memoized for the invocation rather
1045
1349
  than cached with a TTL — closing a pane must make the *next* command report it
@@ -1132,14 +1436,25 @@ Runtime observation and resource status:
1132
1436
  live tmux object still mirrors its `@projmux_window_uid` /
1133
1437
  `@projmux_pane_uid`. A registry object bound to nothing live is an **orphan**,
1134
1438
  and an orphan is not live.
1135
- - `selector.ObservedStatus(missingRoot, bound)` is the **single** derivation
1136
- rule in the codebase, and every kind goes through it. `missing-root` outranks
1137
- everything, then `bound` decides `live` vs `offline`. The MissingRoot
1138
- precedence contract is unchanged, and it now applies to a Window or Pane whose
1139
- owning Project lost its root even while tmux is still running them.
1439
+ - `selector.ObservedStatus(missingRoot, bound, unobserved)` is the **single**
1440
+ derivation rule in the codebase, and every kind goes through it.
1441
+ `missing-root` outranks everything, then `bound` is `live`, then a resource
1442
+ whose observation could not be taken is `unknown`, and only a readable
1443
+ observation with nothing bound is `offline` -- the same precedence the
1444
+ resource graph applies. The MissingRoot precedence contract is unchanged, and
1445
+ it now applies to a Window or Pane whose owning Project lost its root even
1446
+ while tmux is still running them.
1447
+ - `get` and `describe` build their observation from the resolved resource
1448
+ graph, so a row's STATUS and the ACTIONS `registryview` offers for it come
1449
+ from one judgement. An `unknown` row offers only `delete`: it was not seen
1450
+ live, so `open` has nothing to move to, and it was not seen absent, so
1451
+ `start`/`resume` could target something already running.
1140
1452
  - A **Project** is the one kind whose runtime object is a tmux *session*, which
1141
- has no `@projmux` uid of its own, so Project status still reads the stored
1142
- `status.session` projection (see *Resource metadata model* for its writers).
1453
+ has no `@projmux` uid of its own. On `get` and `describe` its status is the
1454
+ graph's observation of that session; an identity-only resolver that took no
1455
+ observation (`selector.New`) still reads the stored `status.session`
1456
+ projection (see *Resource metadata model* for its writers) and never reports
1457
+ `unknown`.
1143
1458
  - An **Agent** owns no tmux object of its own — there is no `@projmux_agent_uid`
1144
1459
  and there must not be one, because an Agent outlives the managed Pane it is
1145
1460
  bound to. Its runtime object is **that managed Pane**, named by
@@ -1166,6 +1481,15 @@ Runtime observation and resource status:
1166
1481
  exactly that. It is also not a per-route reconcile, because the read verbs
1167
1482
  load the registry read-only and must never materialize
1168
1483
  `<state>/projmux/metadata/`.
1484
+ - **The server a view observes** is the one its caller is attached to: the
1485
+ inherited absolute `$TMUX` socket, even when that is not the app server.
1486
+ With no `$TMUX` -- another terminal, an IDE, a script -- `get` and `describe`
1487
+ observe the app socket `-L projmux`, the server `create` and
1488
+ `start project` run resources on, so a view outside tmux renders the same
1489
+ rows as the same view inside the app server. An app socket with no server
1490
+ behind it is observed as an absent server: everything reads `offline`, which
1491
+ is what is running there. `get runtime` and `reconcile resources` keep their
1492
+ own rule (flag or `$TMUX`, otherwise no transport).
1169
1493
  - A failed inventory query yields an **empty** observation, never a fallback to
1170
1494
  a stored value. Empty can only downgrade a resource to offline; it can never
1171
1495
  invent a live one, and "nothing is live" is the truthful answer for a machine
@@ -1651,7 +1975,7 @@ a discovery input *and* the only record that a directory mattered. They are five
1651
1975
  separate authorities now, and the boundaries are the point.
1652
1976
 
1653
1977
  - **Workdirs and project roots are scan roots.** `PROJMUX_MANAGED_ROOTS`,
1654
- `PROJMUX_PROJDIR` and `~/.config/projmux/workdirs` name directories to look
1978
+ `PROJMUX_PROJDIR` and `$HOME/.config/projmux/workdirs` name directories to look
1655
1979
  inside. Looking inside a directory registers nothing. On Windows they are
1656
1980
  OS-native paths and stay OS-native paths; nothing normalizes them into identity.
1657
1981
  - **A discovered child is an unregistered candidate.** It is a filesystem fact