@erclx/aitk 3.46.0 → 3.47.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/REQUIREMENT.md +3 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +18 -2
- package/claude/skills/claude-worker/SKILL.md +41 -4
- package/docs/agents/index.md +1 -1
- package/docs/agents/sessions.md +27 -2
- package/package.json +1 -1
- package/src/commands/sessions.ts +83 -6
- package/src/process/harness.ts +167 -0
- package/src/sessions/resolve.ts +103 -0
|
@@ -82,7 +82,9 @@ The session also records nothing of what it learns. Both other callers of memory
|
|
|
82
82
|
- Check a candidate branch is unclaimed by an existing worktree or a live session before dispatching a background worker for it, since the measured failure this closes is a worker colliding with someone else's work already sitting in the row
|
|
83
83
|
- Dispatch only a candidate whose file set is disjoint from every track in flight, compared at the file path rather than a folder above it, since a count knows nothing about what two workers write
|
|
84
84
|
- Hold a candidate whose sets are disjoint when a stated reason serializes it, and write that reason on the hold, since disjointness is necessary and not sufficient
|
|
85
|
-
- Name each self-dispatched worker with the `
|
|
85
|
+
- Name each self-dispatched worker with the `worker-` prefix, since that is the role the name marks and no controlling session has ever carried it
|
|
86
|
+
- Pass `-n` explicitly on every self-dispatch, since a launch that omits it leaves the client to derive a name and the fallback is a fragment of the session's own identifier
|
|
87
|
+
- Carry the dispatcher's `sessionId` in the launch prompt, since a worker owed two messages has no property on the roster that resolves a controller and a name goes stale inside the window it builds in
|
|
86
88
|
- Report each self-dispatch and the row it fired against loudly enough to follow, since a person no longer watches the launch step happen
|
|
87
89
|
- Stop dispatching once `## Run now` is empty or every row in it reads claimed, rather than waking again to fire on a board nobody is clearing
|
|
88
90
|
|
|
@@ -54,15 +54,31 @@ Name `<model>` on the launch, and pick it against the task rather than copying w
|
|
|
54
54
|
## Dispatch
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
claude --bg --model <model> -n "
|
|
57
|
+
claude --bg --model <model> -n "worker-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship <plan>. Your controller is the session whose sessionId is <dispatcher-id>. Resolve its current name from that id at the moment you send, and never resolve an addressee by name prefix. Message it when the pull request opens, carrying the number, the branch, the head sha, the CI state, and every point you departed from the plan on, and message it again if you stop on a question."
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
`--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name that tells a self-dispatched worker from an operator's own launch in `aitk sessions list`, and `--model` overrides the inheritance the section above measured.
|
|
60
|
+
`--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name that tells a self-dispatched worker from an operator's own launch in `aitk sessions list`, and `--model` overrides the inheritance the section above measured. Pass `-n` on every dispatch rather than letting the client derive one. A launch that omits it leaves the session named for a fragment of its own identifier, which is both its address on the send channel and the whole of what the operator sees for it in agent view.
|
|
61
|
+
|
|
62
|
+
The prefix reads `worker-` because that is the role it marks. It read `orchestrator-` until 2026-08-31, and no controlling session ever carried it, so a worker filtering the roster for that string found a sibling or itself on every row. Nothing matches the prefix programmatically, which is what kept the rename down to three strings.
|
|
63
|
+
|
|
64
|
+
Read `<dispatcher-id>` with `aitk sessions list --self --json` and interpolate the `sessionId` that row carries. Carry the id rather than the name. A name is derived from whatever the session turned out to be doing, and across the 181 records stamping both fields, nine were renamed after launch at a median of 5.4 minutes and a maximum of 509. Three landed more than ten minutes in, which is inside the window a worker announces its pull request in, so a name written into the prompt is aimed at a send that happens after it goes stale.
|
|
65
|
+
|
|
66
|
+
Where the installed CLI answers `--self` with an unknown option, that flag is newer than the release the target holds. Read the `sessionId` from the record the client writes for this session under its configuration directory, and say which route answered so the reader knows whether the id was read or inferred.
|
|
61
67
|
|
|
62
68
|
The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
|
|
63
69
|
|
|
64
70
|
Naming the branch in prose instead was tried and closes nothing, because no tier of that ladder reads the prompt. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. Four workers took the right branch that way, by inference rather than by contract, which is the same judgment both live disagreements came from.
|
|
65
71
|
|
|
72
|
+
### What the brief may carry
|
|
73
|
+
|
|
74
|
+
The prompt carries pointers and standing context, and stops there. The branch and the plan stay arguments, because a skill resolves an argument through a documented ladder and reads no prose at all. What the prose reaches is the worker's judgment, so it holds only what a session has to weigh:
|
|
75
|
+
|
|
76
|
+
- Name the addressee and what it is owed, which the two message clauses above already do.
|
|
77
|
+
- Carry standing context this session holds that a cold one cannot derive, such as a constraint settled in conversation that never reached the plan.
|
|
78
|
+
- Leave out anything scope-shaped. A file list, a naming convention, or a check to run belongs in the plan, where the review reads it back against the diff.
|
|
79
|
+
|
|
80
|
+
The last bullet is the one under pressure, since the dispatch that first proved unattended work possible sent a prompt naming the task file, the likely files, the conventions, and the check to run. It shipped in 874 seconds and touched four files its task never named. Scope that arrives as prose is scope nothing verifies afterward.
|
|
81
|
+
|
|
66
82
|
Report the dispatch as loudly as the human-launch line it replaces: name the branch, the model, the task, and the session name, so a person reading the transcript can follow what fired without watching it happen.
|
|
67
83
|
|
|
68
84
|
## Fall back to the human
|
|
@@ -41,10 +41,47 @@ nothing else.
|
|
|
41
41
|
- Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so a relayed answer arrives under the open question and changes nothing.
|
|
42
42
|
- Send nothing on progress. A worker reporting progress rebuilds, on this side of the channel, the poll the announcement retired on the other.
|
|
43
43
|
|
|
44
|
-
Address the session the launch named.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
Address the session the launch named. It names a `sessionId` rather than a name,
|
|
45
|
+
so read `aitk sessions list --json`, find the row carrying that id, and send to
|
|
46
|
+
the `name` on it. Resolve that name at the moment of sending rather than at
|
|
47
|
+
launch, since a name is derived from what a session turned out to be doing and
|
|
48
|
+
has gone stale inside the hour that a build takes.
|
|
49
|
+
|
|
50
|
+
Check that name against the agent listing before sending it. A name is not
|
|
51
|
+
unique, and the roster carries no field that separates two live sessions holding
|
|
52
|
+
one, so the resolution can end on a string that reaches the wrong session. Send
|
|
53
|
+
the name bare where the listing shows one row under it. Where it shows more than
|
|
54
|
+
one, complete the address with the `[ref]` that listing prints beside each row
|
|
55
|
+
rather than sending to the name alone, which lands on whichever row the channel
|
|
56
|
+
resolves first. Two sessions differing only by a trailing ` (3)` is the shape
|
|
57
|
+
this meets in practice, and one of them being a controller is what makes the
|
|
58
|
+
wrong pick silent.
|
|
59
|
+
|
|
60
|
+
Ask the operator when the launch named nobody and a person is there to answer.
|
|
61
|
+
Put the candidate rows to them through the structured question surface, so they
|
|
62
|
+
pick a row rather than recalling a name. The ask halts the build, and the halt
|
|
63
|
+
is the cheaper error: a worker that cannot reach its controller has nothing
|
|
64
|
+
useful to do with the message it owes, where sending to the wrong session
|
|
65
|
+
reports success and loses it. A halt is only as visible as whatever watches for
|
|
66
|
+
one, so say what you are waiting on in the same turn you stop, and expect a
|
|
67
|
+
controller running no stall detector to find the question only when it next
|
|
68
|
+
looks.
|
|
69
|
+
|
|
70
|
+
Infer only where no operator is present. Read `aitk sessions list --json` and
|
|
71
|
+
take the sessions holding no feature branch as the candidates, since a
|
|
72
|
+
controlling session holds none. Say the addressee was inferred so the reader can
|
|
73
|
+
correct it. Never filter that roster by name prefix: every self-dispatched
|
|
74
|
+
worker is named `worker-<slug>`, so a prefix scan returns a sibling or this
|
|
75
|
+
session itself, which is the defect that sent messages owed to a controller
|
|
76
|
+
somewhere else.
|
|
77
|
+
|
|
78
|
+
Report a resolution that returns nothing rather than falling back to a guess.
|
|
79
|
+
The roster and the send channel enumerate different populations in both
|
|
80
|
+
directions, measured at one moment: a live background session sat on the roster
|
|
81
|
+
that the agent listing did not carry, and nine sessions were addressable there
|
|
82
|
+
with no roster row at all. Those nine were driving through Remote Control, which
|
|
83
|
+
writes no local process record, so a controller working from a phone is exactly
|
|
84
|
+
the case this read answers nothing for.
|
|
48
85
|
|
|
49
86
|
## Refusing is part of the job
|
|
50
87
|
|
package/docs/agents/index.md
CHANGED
|
@@ -30,7 +30,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
30
30
|
- [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
|
|
31
31
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
|
32
32
|
- [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
|
|
33
|
-
- [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds,
|
|
33
|
+
- [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
|
|
34
34
|
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
35
35
|
- [Citation reach](skills-reach.md): Reporting the skill bodies that cite a path no target project receives, which corpus the verb reads, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
|
|
36
36
|
- [Standard success criteria](standards-audit.md): Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
|
package/docs/agents/sessions.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Sessions
|
|
3
|
-
description: Resolving live peer sessions to the worktree and branch each holds,
|
|
3
|
+
description: Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Sessions
|
|
@@ -14,6 +14,7 @@ aitk sessions list
|
|
|
14
14
|
aitk sessions list --json
|
|
15
15
|
aitk sessions list --branch feat/parser --json
|
|
16
16
|
aitk sessions list --branch chore/agents --repository ../caret --json
|
|
17
|
+
aitk sessions list --self --json
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
| Option | Behavior |
|
|
@@ -21,10 +22,11 @@ aitk sessions list --branch chore/agents --repository ../caret --json
|
|
|
21
22
|
| `--json` | Add a machine-readable record on stdout |
|
|
22
23
|
| `--branch <name>` | Report the sessions holding this branch |
|
|
23
24
|
| `--repository <path>` | Answer about this project rather than the working one |
|
|
25
|
+
| `--self` | Report the caller's own row |
|
|
24
26
|
|
|
25
27
|
It reads and never writes. The question it answers is which session to address when work has to reach the one holding a given branch, which a session listing cannot answer on its own.
|
|
26
28
|
|
|
27
|
-
Exit codes: `0` the roster was read, `1` refused. The refusal carries a `reason` of `no-registry` or `no-
|
|
29
|
+
Exit codes: `0` the roster was read, `1` refused. The refusal carries a `reason` of `no-registry`, `no-repository`, `no-self-identity`, or `no-self-row`.
|
|
28
30
|
|
|
29
31
|
An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this.
|
|
30
32
|
|
|
@@ -52,6 +54,29 @@ The ref read covers the local head and the remote-tracking ref, which means it s
|
|
|
52
54
|
|
|
53
55
|
Two flags say which reading came up short. `sessionsReadable` is `false` when the session roster could not be read, and `refsReadable` is `false` when the ref read failed. Either one leaves `claimed` covering the readings around it alone, so a `false` there is a report that ran short of evidence rather than a report that the branch is clear. They stay separate fields because a caller told the roster failed goes and looks at the roster, and folding both into one flag would send it to the wrong place.
|
|
54
56
|
|
|
57
|
+
## Which row is the caller
|
|
58
|
+
|
|
59
|
+
`--self` narrows the report to the row belonging to the session making the call. A dispatcher reads it to learn the `sessionId` it carries into a launch, because the roster returns every field on every row and marks none of them as the caller.
|
|
60
|
+
|
|
61
|
+
Three identifier namespaces reach a session and two of them join to a row. Four variables spell them:
|
|
62
|
+
|
|
63
|
+
- `CLAUDE_CODE_SESSION_ID` holds the roster's own `sessionId`, and is read first because it survives a rename.
|
|
64
|
+
- `CLAUDE_PID` holds the caller's process id, which every row already carries as `pid`.
|
|
65
|
+
- `CLAUDE_CODE_MESSAGING_SOCKET` spells that same pid in its basename. It is read last, since the spelling is a client convention rather than a published interface and a client that moves the socket drops this rung alone.
|
|
66
|
+
- `CLAUDE_CODE_HOST_SESSION_ID` is never read. It carries a `local_`-prefixed value from the harness namespace that matches no row, and it is the variable a reader searching the environment for a session id finds first.
|
|
67
|
+
|
|
68
|
+
The read refuses rather than returning an empty roster, on the same ground as `no-registry`. A `no-self-identity` refusal means the environment stated none of the three, which is a client identifying its sessions some other way. A `no-self-row` refusal means it stated one and no live row carries it, which is what a session running outside a local process gets.
|
|
69
|
+
|
|
70
|
+
## What each surface can see
|
|
71
|
+
|
|
72
|
+
The roster this verb reads and the channel a session sends messages on enumerate different populations, and neither contains the other. Read at one moment, `aitk sessions list` returned 6 rows against the 13 peers plus caller the agent listing reported. The roster held a background session at confirmed confidence on two reads a minute apart that the listing never carried, so a live session sat here with no way to reach it. Nine Remote Control sessions ran the other way, reachable there with no row here at all, since they leave no local process for the registry to record.
|
|
73
|
+
|
|
74
|
+
That bounds what a carried identifier buys. Resolving a `sessionId` to a name here can produce a name the send channel rejects, and `--self` answers nothing for a controller driving from Remote Control, which is the operator working from their phone rather than an edge case. A caller that cannot resolve an addressee reports the failed resolution instead of falling back to a guess.
|
|
75
|
+
|
|
76
|
+
The `name` field is what both surfaces agree on, and that agreement is what makes the resolution work. `SendMessage` takes a name and carries no identifier parameter, so the carry runs id to name to send rather than sending an id anywhere.
|
|
77
|
+
|
|
78
|
+
Agreement is not uniqueness, and the last hop is where that bites. Two live sessions can hold one name, the roster carries no field that separates them, and a send to the bare name reaches whichever the channel resolves first. The agent listing prints a ref beside each row and the roster does not, so a name resolving to more than one live row is completed from that listing rather than from anything this verb returns.
|
|
79
|
+
|
|
55
80
|
## Why the verb exists
|
|
56
81
|
|
|
57
82
|
A session listing reports a name, a kind, a status, and how long each session has been running. None of those names a branch. Resolving a branch to a session therefore meant ordering the roster by start time and matching it against the order the worktrees were created, which is an inference that fails whenever two sessions start inside the same minute.
|
package/package.json
CHANGED
package/src/commands/sessions.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { resolve } from 'node:path'
|
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { checkClaim, type ClaimReport } from '@/sessions/claim'
|
|
4
4
|
import {
|
|
5
|
+
callerIdentity,
|
|
5
6
|
repositoryOf,
|
|
6
7
|
type ResolvedSession,
|
|
7
8
|
resolveSessions,
|
|
9
|
+
type SelfReport,
|
|
8
10
|
type SessionReport,
|
|
11
|
+
selfOf,
|
|
9
12
|
} from '@/sessions/resolve'
|
|
10
13
|
import {
|
|
11
14
|
intro,
|
|
@@ -21,6 +24,7 @@ interface ListCommandOptions {
|
|
|
21
24
|
readonly json?: boolean
|
|
22
25
|
readonly branch?: string
|
|
23
26
|
readonly repository?: string
|
|
27
|
+
readonly self?: boolean
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
const REASONS: Record<string, string> = {
|
|
@@ -52,6 +56,10 @@ export function register(program: Command): void {
|
|
|
52
56
|
'--repository <path>',
|
|
53
57
|
'Answer about this project rather than the working one',
|
|
54
58
|
)
|
|
59
|
+
.option(
|
|
60
|
+
'--self',
|
|
61
|
+
"Report the caller's own row, and refuse where the roster holds none",
|
|
62
|
+
)
|
|
55
63
|
.addHelpText(
|
|
56
64
|
'after',
|
|
57
65
|
[
|
|
@@ -93,6 +101,19 @@ export function register(program: Command): void {
|
|
|
93
101
|
'The match can return more than one session. Read the count rather than',
|
|
94
102
|
'the first row, since two sessions can hold one branch.',
|
|
95
103
|
'',
|
|
104
|
+
"--self narrows the report to the caller's own row, which is what a",
|
|
105
|
+
'dispatcher reads to learn the sessionId it carries into a launch. It',
|
|
106
|
+
'joins on CLAUDE_CODE_SESSION_ID first, falls back to CLAUDE_PID, and',
|
|
107
|
+
'falls back again to the pid the messaging socket path spells. It never',
|
|
108
|
+
'reads CLAUDE_CODE_HOST_SESSION_ID, which holds a value from another',
|
|
109
|
+
'namespace that matches no row.',
|
|
110
|
+
'',
|
|
111
|
+
'It refuses with reason "no-self-identity" when the environment states',
|
|
112
|
+
'none of the three, and "no-self-row" when it states one and no live',
|
|
113
|
+
'row carries it. The second is the ordinary answer for a session',
|
|
114
|
+
'driving from Remote Control, which is addressable on the message',
|
|
115
|
+
'channel and holds no local process record for the roster to report.',
|
|
116
|
+
'',
|
|
96
117
|
'Each session writes its own working directory beside its own name, so a',
|
|
97
118
|
'name from a session listing joins to a branch by an exact match rather',
|
|
98
119
|
'than by ordering the roster on start time.',
|
|
@@ -108,6 +129,7 @@ export function register(program: Command): void {
|
|
|
108
129
|
' aitk sessions list --json',
|
|
109
130
|
' aitk sessions list --branch feat/parser --json',
|
|
110
131
|
' aitk sessions list --branch chore/agents --repository ../caret --json',
|
|
132
|
+
' aitk sessions list --self --json',
|
|
111
133
|
'',
|
|
112
134
|
].join('\n'),
|
|
113
135
|
)
|
|
@@ -136,6 +158,34 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
136
158
|
return 1
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
// The roster read returns every row and marks none of them as the caller, so
|
|
162
|
+
// the join runs here, ahead of any scope. Resolving it after the branch
|
|
163
|
+
// filter would answer "no row" for a caller whose row was merely filtered
|
|
164
|
+
// out, which is a different failure wearing the same reason.
|
|
165
|
+
const own = opts.self ? selfOf(report.sessions, callerIdentity()) : null
|
|
166
|
+
|
|
167
|
+
if (own?.kind === 'unresolved') {
|
|
168
|
+
intro('aitk sessions list')
|
|
169
|
+
logStep('Refused')
|
|
170
|
+
logWarn(selfRefusal(own))
|
|
171
|
+
outro()
|
|
172
|
+
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
process.stdout.write(
|
|
175
|
+
`${JSON.stringify({
|
|
176
|
+
dir: report.dir,
|
|
177
|
+
reason:
|
|
178
|
+
own.reason === 'no-identity' ? 'no-self-identity' : 'no-self-row',
|
|
179
|
+
sessions: [],
|
|
180
|
+
})}\n`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pool = own === null ? report.sessions : [own.session]
|
|
188
|
+
|
|
139
189
|
// A branch name identifies a branch inside one repository and nothing across
|
|
140
190
|
// a machine, so an unscoped match reaches a session working in a different
|
|
141
191
|
// project. `main` is the name that collides on every machine running two.
|
|
@@ -164,11 +214,11 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
const shown = opts.branch
|
|
167
|
-
?
|
|
217
|
+
? pool.filter(
|
|
168
218
|
(session) =>
|
|
169
219
|
session.branch === opts.branch && session.repository === repository,
|
|
170
220
|
)
|
|
171
|
-
:
|
|
221
|
+
: pool
|
|
172
222
|
|
|
173
223
|
const claim = opts.branch
|
|
174
224
|
? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
|
|
@@ -176,7 +226,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
176
226
|
|
|
177
227
|
intro('aitk sessions list')
|
|
178
228
|
reportConfidence(report)
|
|
179
|
-
reportSessions(shown, opts.branch, repository)
|
|
229
|
+
reportSessions(shown, opts.branch, repository, own !== null)
|
|
180
230
|
if (claim) reportClaim(claim)
|
|
181
231
|
outro()
|
|
182
232
|
|
|
@@ -200,6 +250,22 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
200
250
|
return 0
|
|
201
251
|
}
|
|
202
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Separates a client that states no identity from a roster holding no row for
|
|
255
|
+
* one it does state, since the two send a reader to different places.
|
|
256
|
+
*/
|
|
257
|
+
function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
|
|
258
|
+
if (own.reason === 'no-identity') {
|
|
259
|
+
return 'Nothing in the environment identifies this session, so --self has nothing to match against. A client setting none of CLAUDE_CODE_SESSION_ID, CLAUDE_PID, or CLAUDE_CODE_MESSAGING_SOCKET cannot be located on the roster at all.'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const held =
|
|
263
|
+
own.identity.sessionId ??
|
|
264
|
+
(own.identity.pid === null ? 'nothing' : `pid ${own.identity.pid}`)
|
|
265
|
+
|
|
266
|
+
return `The environment identifies this session as ${held}, and no live row carries it. The roster holds local process records alone, so a session driving through Remote Control never appears here, and a record whose session has ended is dropped ahead of the match.`
|
|
267
|
+
}
|
|
268
|
+
|
|
203
269
|
/**
|
|
204
270
|
* States how liveness was decided on every run, including the run that decided
|
|
205
271
|
* it the strong way.
|
|
@@ -239,6 +305,7 @@ function reportSessions(
|
|
|
239
305
|
sessions: readonly ResolvedSession[],
|
|
240
306
|
branch: string | undefined,
|
|
241
307
|
repository: string | null,
|
|
308
|
+
scoped: boolean,
|
|
242
309
|
): void {
|
|
243
310
|
logStep('Sessions')
|
|
244
311
|
|
|
@@ -248,11 +315,21 @@ function reportSessions(
|
|
|
248
315
|
)
|
|
249
316
|
}
|
|
250
317
|
|
|
318
|
+
// An empty result under --self says nothing about the roster, since the pool
|
|
319
|
+
// was narrowed to one row before the branch filter ran. Reporting the wider
|
|
320
|
+
// answer there would claim a reading this run never took.
|
|
251
321
|
if (sessions.length === 0) {
|
|
322
|
+
if (branch) {
|
|
323
|
+
logInfo(
|
|
324
|
+
scoped
|
|
325
|
+
? `This session does not hold ${branch}.`
|
|
326
|
+
: `No live session in this repository holds ${branch}.`,
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
252
331
|
logInfo(
|
|
253
|
-
|
|
254
|
-
? `No live session in this repository holds ${branch}.`
|
|
255
|
-
: 'No live session. Every record in the registry belongs to a session that has ended.',
|
|
332
|
+
'No live session. Every record in the registry belongs to a session that has ended.',
|
|
256
333
|
)
|
|
257
334
|
return
|
|
258
335
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { stateDir } from '@/targets/registry'
|
|
6
|
+
|
|
7
|
+
const CLI = join(PROJECT_ROOT, 'src/cli.ts')
|
|
8
|
+
|
|
9
|
+
/** No case has ever needed longer, and a blocked verb should fail fast. */
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
11
|
+
|
|
12
|
+
export interface ProcessRun {
|
|
13
|
+
readonly status: number | null
|
|
14
|
+
readonly stdout: string
|
|
15
|
+
readonly stderr: string
|
|
16
|
+
readonly json: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunCliOptions {
|
|
20
|
+
readonly cwd: string
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv
|
|
22
|
+
readonly timeoutMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when a case reaches past its declared temporary directory into this
|
|
27
|
+
* machine's real toolkit state. `stateDir()` in `src/targets/registry.ts`
|
|
28
|
+
* holds both the target registry `gov install` and `gov sync` record into and
|
|
29
|
+
* the sandbox tree `aitk sandbox` provisions into, so a case that inherits the
|
|
30
|
+
* real `HOME` unmodified writes into whichever of the two a verb touches, and
|
|
31
|
+
* nothing but this check would ever say so.
|
|
32
|
+
*/
|
|
33
|
+
export class ContainmentViolation extends Error {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compares two snapshots of this machine's real toolkit state directory and
|
|
37
|
+
* reports whether a spawn changed it. A pure comparison over the two reads
|
|
38
|
+
* rather than the read itself, so the detection logic is testable without
|
|
39
|
+
* touching the filesystem or spawning anything.
|
|
40
|
+
*/
|
|
41
|
+
export function detectStateLeak(
|
|
42
|
+
before: string | undefined,
|
|
43
|
+
after: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return before !== after
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A sorted `path:size` listing of every file under this machine's real
|
|
50
|
+
* `stateDir()`, walked recursively rather than read one level deep, so a
|
|
51
|
+
* write nested inside an existing folder, such as a file the sandbox tree
|
|
52
|
+
* already holds, shows up the same as a new top-level entry. Reading a single
|
|
53
|
+
* known file, such as the target registry alone, would miss every sibling
|
|
54
|
+
* `stateDir()` grows, which is what left the sandbox tree unwatched.
|
|
55
|
+
*/
|
|
56
|
+
export function snapshotStateDir(): string {
|
|
57
|
+
const root = stateDir()
|
|
58
|
+
const rows: string[] = []
|
|
59
|
+
|
|
60
|
+
function walk(dir: string): void {
|
|
61
|
+
let names: string[]
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(dir)
|
|
64
|
+
} catch {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const name of names.sort()) {
|
|
69
|
+
const full = join(dir, name)
|
|
70
|
+
let info: ReturnType<typeof statSync>
|
|
71
|
+
try {
|
|
72
|
+
info = statSync(full)
|
|
73
|
+
} catch {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (info.isDirectory()) walk(full)
|
|
77
|
+
else rows.push(`${full}:${info.size}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
walk(root)
|
|
82
|
+
return rows.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Spawns the real entry point rather than calling a command's action function
|
|
87
|
+
* in-process, so a case answers whether a verb is registered, whether it
|
|
88
|
+
* exits the way its own contract states, and whether its `--json` record
|
|
89
|
+
* parses off stdout alone, none of which an in-process call can misreport.
|
|
90
|
+
*
|
|
91
|
+
* A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
|
|
92
|
+
* reads against this checkout instead of the temporary directory a case
|
|
93
|
+
* builds, so every spawn drops the `GIT_` prefix before adding the headless
|
|
94
|
+
* flag every case needs to avoid a picker blocking on stdin.
|
|
95
|
+
*
|
|
96
|
+
* `AITK_STATE_DIR` and `AITK_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
|
|
97
|
+
* each pointed at a folder under the case's own `cwd` rather than dropped,
|
|
98
|
+
* since dropping either alone would still resolve through the inherited
|
|
99
|
+
* `HOME` to this machine's real `~/.local/state/aitk`. `stateDir()` and
|
|
100
|
+
* `sandboxTree()` resolve the same three ways and share that parent, so both
|
|
101
|
+
* overrides move together. A case explicitly passing its own value through
|
|
102
|
+
* `options.env` still wins, matching `AITK_NON_INTERACTIVE` below.
|
|
103
|
+
*
|
|
104
|
+
* The `stateDir()` snapshot before and after the spawn is what actually
|
|
105
|
+
* catches an escape past that redirection, since a default can be wrong in a
|
|
106
|
+
* way a case never asserts on its own, and it is what `AITK_SANDBOX_DIR`
|
|
107
|
+
* rides for free: the sandbox tree already sits under `stateDir()`, so
|
|
108
|
+
* walking the whole directory catches a leak there with no override of its
|
|
109
|
+
* own to add. `ContainmentViolation` fails loud rather than leaving a dead
|
|
110
|
+
* row for a reviewer to find on a real machine.
|
|
111
|
+
*/
|
|
112
|
+
export function runCli(
|
|
113
|
+
args: readonly string[],
|
|
114
|
+
options: RunCliOptions,
|
|
115
|
+
): ProcessRun {
|
|
116
|
+
const inherited = Object.fromEntries(
|
|
117
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const before = snapshotStateDir()
|
|
121
|
+
|
|
122
|
+
const result = spawnSync('bun', [CLI, ...args], {
|
|
123
|
+
cwd: options.cwd,
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
126
|
+
env: {
|
|
127
|
+
...inherited,
|
|
128
|
+
AITK_NON_INTERACTIVE: '1',
|
|
129
|
+
AITK_STATE_DIR: join(options.cwd, '.aitk-state'),
|
|
130
|
+
AITK_SANDBOX_DIR: join(options.cwd, '.aitk-state', 'sandbox'),
|
|
131
|
+
...options.env,
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const after = snapshotStateDir()
|
|
136
|
+
if (detectStateLeak(before, after)) {
|
|
137
|
+
throw new ContainmentViolation(
|
|
138
|
+
`A case wrote into this machine's real toolkit state at ${stateDir()}. ` +
|
|
139
|
+
'Every process-tier case must stay inside the directory it declared.',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
status: result.status,
|
|
145
|
+
stdout: result.stdout,
|
|
146
|
+
stderr: result.stderr,
|
|
147
|
+
json: parseJson(result.stdout),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Data goes to stdout and framing to stderr, so a harness reading `--json`
|
|
153
|
+
* off the merged output would assert against a record no verb ever wrote. A
|
|
154
|
+
* command that emits no JSON, or fails before it gets there, leaves the
|
|
155
|
+
* field `undefined` rather than throwing, so a case asserting the exit code
|
|
156
|
+
* of a refusal is not also forced to guard a parse.
|
|
157
|
+
*/
|
|
158
|
+
function parseJson(stdout: string): unknown {
|
|
159
|
+
const trimmed = stdout.trim()
|
|
160
|
+
if (trimmed === '') return undefined
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(trimmed)
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/sessions/resolve.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
1
2
|
import { $ } from 'bun'
|
|
2
3
|
import { gitEnv } from '@/git-env'
|
|
3
4
|
import {
|
|
@@ -186,3 +187,105 @@ export async function resolveSessions(
|
|
|
186
187
|
|
|
187
188
|
return { kind: 'resolved', dir: registry.dir, confidence, sessions }
|
|
188
189
|
}
|
|
190
|
+
|
|
191
|
+
/** The environment slice the caller's own identity is read from. */
|
|
192
|
+
export type Env = Record<string, string | undefined>
|
|
193
|
+
|
|
194
|
+
/** Why the caller's own row could not be named, rather than an empty result. */
|
|
195
|
+
export type SelfUnresolved = 'no-identity' | 'no-row'
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What the environment offers about the session making the call.
|
|
199
|
+
*
|
|
200
|
+
* Both fields are candidates rather than a pair, since a client sets them
|
|
201
|
+
* independently and a caller can arrive carrying either one alone.
|
|
202
|
+
*/
|
|
203
|
+
export interface SelfIdentity {
|
|
204
|
+
readonly sessionId: string | null
|
|
205
|
+
readonly pid: number | null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type SelfReport =
|
|
209
|
+
| { readonly kind: 'self'; readonly session: ResolvedSession }
|
|
210
|
+
| {
|
|
211
|
+
readonly kind: 'unresolved'
|
|
212
|
+
readonly reason: SelfUnresolved
|
|
213
|
+
readonly identity: SelfIdentity
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A positive integer, which is what separates a pid from a socket named for
|
|
218
|
+
* something else. Signal zero addresses the caller's own process group, so a
|
|
219
|
+
* zero would match a row rather than failing to.
|
|
220
|
+
*/
|
|
221
|
+
function pidOf(value: string | undefined): number | null {
|
|
222
|
+
if (value === undefined) return null
|
|
223
|
+
const pid = Number(value.trim())
|
|
224
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Reads whatever the environment states about the calling session.
|
|
229
|
+
*
|
|
230
|
+
* Three identifier namespaces are in play and only two of them join to a
|
|
231
|
+
* roster row. `CLAUDE_CODE_SESSION_ID` is the roster's own `sessionId` and is
|
|
232
|
+
* read first because it survives a rename, where the name a session carries is
|
|
233
|
+
* derived from what it turned out to be doing and rotates while a build runs.
|
|
234
|
+
* `CLAUDE_CODE_HOST_SESSION_ID` is deliberately never read: it holds a
|
|
235
|
+
* `local_`-prefixed value from the harness namespace that matches no row, and
|
|
236
|
+
* it is the variable a reader searching the environment for a session id finds
|
|
237
|
+
* first.
|
|
238
|
+
*
|
|
239
|
+
* The socket path is the last rung because its basename is the caller's pid by
|
|
240
|
+
* a client convention rather than a published interface, so a client that
|
|
241
|
+
* moves it drops this rung while leaving the two above it standing.
|
|
242
|
+
*/
|
|
243
|
+
export function callerIdentity(env: Env = process.env): SelfIdentity {
|
|
244
|
+
const stated = env.CLAUDE_CODE_SESSION_ID?.trim()
|
|
245
|
+
const socket = env.CLAUDE_CODE_MESSAGING_SOCKET
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
sessionId: stated !== undefined && stated.length > 0 ? stated : null,
|
|
249
|
+
pid:
|
|
250
|
+
pidOf(env.CLAUDE_PID) ??
|
|
251
|
+
pidOf(
|
|
252
|
+
socket === undefined
|
|
253
|
+
? undefined
|
|
254
|
+
: basename(socket).replace(/\.sock$/, ''),
|
|
255
|
+
),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Names which row of a roster belongs to the caller.
|
|
261
|
+
*
|
|
262
|
+
* The read that already returns every field marks none of them as the caller,
|
|
263
|
+
* so this performs the join rather than adding a source. An identity the
|
|
264
|
+
* environment does not carry and an identity no row matches are separated,
|
|
265
|
+
* because the first is a client that states nothing and the second is a
|
|
266
|
+
* session the roster cannot see. A session driving from Remote Control is the
|
|
267
|
+
* measured instance of the second: it is addressable on the message channel
|
|
268
|
+
* and holds no local process record for the roster to report.
|
|
269
|
+
*/
|
|
270
|
+
export function selfOf(
|
|
271
|
+
sessions: readonly ResolvedSession[],
|
|
272
|
+
identity: SelfIdentity,
|
|
273
|
+
): SelfReport {
|
|
274
|
+
if (identity.sessionId === null && identity.pid === null) {
|
|
275
|
+
return { kind: 'unresolved', reason: 'no-identity', identity }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const byId =
|
|
279
|
+
identity.sessionId === null
|
|
280
|
+
? undefined
|
|
281
|
+
: sessions.find((session) => session.sessionId === identity.sessionId)
|
|
282
|
+
const byPid =
|
|
283
|
+
identity.pid === null
|
|
284
|
+
? undefined
|
|
285
|
+
: sessions.find((session) => session.pid === identity.pid)
|
|
286
|
+
const match = byId ?? byPid
|
|
287
|
+
|
|
288
|
+
return match === undefined
|
|
289
|
+
? { kind: 'unresolved', reason: 'no-row', identity }
|
|
290
|
+
: { kind: 'self', session: match }
|
|
291
|
+
}
|