@timurproko/a1 0.2.6-dev.734 → 0.2.6
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/dist/app/session-shell/index.d.ts +2 -2
- package/dist/app/session-shell/index.js +1 -1
- package/dist/app/session-shell/session-shell-root.d.ts +9 -1
- package/dist/app/session-shell/session-shell-root.js +56 -47
- package/dist/app/session-shell/session-shell.d.ts +37 -9
- package/dist/app/session-shell/session-shell.js +266 -446
- package/dist/app/session-shell/shell-host.d.ts +105 -0
- package/dist/app/session-shell/shell-host.js +523 -0
- package/dist/cli/session-context.js +6 -3
- package/dist/composition/owned-ui.js +45 -25
- package/dist/contracts/owned-ui/index.d.ts +1 -1
- package/dist/contracts/owned-ui/session-presenters.d.ts +35 -8
- package/dist/features/launch/index.d.ts +1 -1
- package/dist/features/launch/index.js +1 -1
- package/dist/features/launch/session-context.d.ts +1 -0
- package/dist/features/launch/session-context.js +1 -0
- package/dist/features/owned-ui/resources/release-notes.json +4 -0
- package/dist/features/owned-ui/settings-app.js +3 -3
- package/dist/foundation/lifecycle/index.d.ts +1 -1
- package/dist/foundation/lifecycle/index.js +1 -1
- package/dist/foundation/lifecycle/session-repository-context.d.ts +8 -2
- package/dist/foundation/lifecycle/session-repository-context.js +200 -65
- package/dist/foundation/release/cohort-state.js +5 -2
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +11 -0
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +18 -2
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/ui/components/value-menu.d.ts +6 -3
- package/dist/ui/components/value-menu.js +6 -5
- package/docs/architecture/boundaries.md +1 -1
- package/docs/architecture/evidence/terminal-host-spike/manual-one-pane-terminal-host.md +70 -0
- package/docs/architecture/evidence/terminal-host-spike/manual-two-by-two-basic-terminal-host.md +32 -0
- package/docs/architecture/evidence/terminal-host-spike/milestone-baseline.json +115 -0
- package/docs/architecture/evidence/terminal-host-spike/owned-ui-foundation-prerequisite.json +48 -0
- package/docs/architecture/evidence/terminal-host-spike/terminal-host-provenance.json +125 -0
- package/docs/architecture/evidence/terminal-host-spike/terminal-host-task-5-1.json +56 -0
- package/docs/architecture/evidence/terminal-host-spike/terminal-host-task-5-3.json +174 -0
- package/docs/architecture/evidence/terminal-host-spike/terminal-spike-acceptance-record.json +18 -0
- package/docs/architecture/terminal-host-proof-gate.md +1 -1
- package/docs/architecture/terminal-host-spike-evidence.md +1 -1
- package/docs/architecture/ui-reference-provenance.md +1 -1
- package/docs/releases/0.2.6.md +44 -0
- package/package.json +1 -1
|
@@ -13,52 +13,60 @@ const MAX_WORKTREES = 256;
|
|
|
13
13
|
const GIT_TIMEOUT_MS = 5_000;
|
|
14
14
|
const LOCK_TIMEOUT_MS = 2_000;
|
|
15
15
|
const LOCK_RETRY_MS = 20;
|
|
16
|
+
/** Records without an agent id were written by a single-agent release and belong to its one agent. */
|
|
17
|
+
export const PRIMARY_SESSION_AGENT_ID = "primary";
|
|
18
|
+
const RECORD_FORMAT_VERSION = 2;
|
|
16
19
|
/** Publish the exact live runtime that may acquire a delivery worktree. */
|
|
17
20
|
export async function registerSessionRepositoryRuntime(identity, runtimeId, runtimeIdentity, options = {}) {
|
|
18
21
|
const header = await readSessionHeader(identity);
|
|
19
22
|
assertRuntimeId(runtimeId);
|
|
23
|
+
const agentId = requireAgentId(options);
|
|
20
24
|
assertProcessIdentity(runtimeIdentity);
|
|
21
|
-
await withClaimLock(options, async () => {
|
|
22
|
-
await writeRuntimeRecord(header, runtimeId, runtimeIdentity, options);
|
|
25
|
+
await withClaimLock({ ...options, runtimeIdentity }, async () => {
|
|
26
|
+
await writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, options);
|
|
23
27
|
});
|
|
24
28
|
}
|
|
25
|
-
/** Revalidate a durable association and acquire its live claim for this runtime. */
|
|
29
|
+
/** Revalidate a durable association and acquire its live claim for this runtime's agent; sibling agents' claims stay. */
|
|
26
30
|
export async function activateSessionRepositoryContext(identity, runtimeId, runtimeIdentity, options = {}) {
|
|
27
31
|
const header = await readSessionHeader(identity);
|
|
28
32
|
assertRuntimeId(runtimeId);
|
|
33
|
+
const agentId = requireAgentId(options);
|
|
29
34
|
assertProcessIdentity(runtimeIdentity);
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const owner = { runtimeId, agentId };
|
|
36
|
+
return await withClaimLock({ ...options, runtimeIdentity }, async () => {
|
|
37
|
+
await writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, options);
|
|
32
38
|
const record = await readContextRecord(header, options);
|
|
33
39
|
if (record === null) {
|
|
34
|
-
await releaseClaimsOwnedBy(
|
|
40
|
+
await releaseClaimsOwnedBy(owner, null, options);
|
|
35
41
|
return null;
|
|
36
42
|
}
|
|
37
43
|
const selected = await validateContextRecord(header, record, options.signal).catch(() => null);
|
|
38
44
|
if (selected === null) {
|
|
39
|
-
await releaseClaimsOwnedBy(
|
|
45
|
+
await releaseClaimsOwnedBy(owner, null, options);
|
|
40
46
|
return null;
|
|
41
47
|
}
|
|
42
|
-
const status = await claimAvailability(selected,
|
|
48
|
+
const status = await claimAvailability(selected, owner, options);
|
|
43
49
|
if (status !== "available" && status !== "current") {
|
|
44
|
-
await releaseClaimsOwnedBy(
|
|
50
|
+
await releaseClaimsOwnedBy(owner, null, options);
|
|
45
51
|
return null;
|
|
46
52
|
}
|
|
47
|
-
await writeClaim(header, selected,
|
|
48
|
-
await releaseClaimsOwnedBy(
|
|
53
|
+
await writeClaim(header, selected, owner, runtimeIdentity, options);
|
|
54
|
+
await releaseClaimsOwnedBy(owner, selected.gitDir, options);
|
|
49
55
|
return { cwd: selected.root, branch: requireBranch(selected) };
|
|
50
56
|
});
|
|
51
57
|
}
|
|
52
|
-
/** Release
|
|
58
|
+
/** Release every agent's cooperative editing claims held by this exact runtime generation. */
|
|
53
59
|
export async function releaseSessionRepositoryRuntime(runtimeId, runtimeIdentity, options = {}) {
|
|
54
60
|
assertRuntimeId(runtimeId);
|
|
55
61
|
assertProcessIdentity(runtimeIdentity);
|
|
56
|
-
await withClaimLock(options, async () => {
|
|
57
|
-
const
|
|
58
|
-
|
|
62
|
+
await withClaimLock({ ...options, runtimeIdentity }, async () => {
|
|
63
|
+
const runtimes = (await readRuntimeRecordsOf(runtimeId, options))
|
|
64
|
+
.filter(({ record }) => record.pid === runtimeIdentity.pid && record.startIdentity === runtimeIdentity.startIdentity);
|
|
65
|
+
if (runtimes.length === 0)
|
|
59
66
|
return;
|
|
60
|
-
await
|
|
61
|
-
|
|
67
|
+
await releaseRuntimeClaims(runtimeId, options);
|
|
68
|
+
for (const { path } of runtimes)
|
|
69
|
+
await rm(path, { force: true });
|
|
62
70
|
});
|
|
63
71
|
}
|
|
64
72
|
/** Persist one explicit same-repository worktree association after atomically claiming it. */
|
|
@@ -69,27 +77,27 @@ export async function setSessionRepositoryContext(identity, worktreePath, option
|
|
|
69
77
|
if (!samePath(startup.commonDir, selected.commonDir)) {
|
|
70
78
|
throw contextError("associated worktree belongs to a different Git repository", "worktree-foreign-repository");
|
|
71
79
|
}
|
|
72
|
-
const
|
|
80
|
+
const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
|
|
73
81
|
return await withClaimLock(options, async () => {
|
|
74
|
-
const runtime = await requireLiveRuntime(header,
|
|
75
|
-
const status = await claimAvailability(selected,
|
|
82
|
+
const runtime = await requireLiveRuntime(header, owner, options);
|
|
83
|
+
const status = await claimAvailability(selected, owner, options);
|
|
76
84
|
if (status === "busy")
|
|
77
85
|
throw contextError("worktree is active in another session", "worktree-active-in-another-session");
|
|
78
86
|
if (status === "unverifiable")
|
|
79
87
|
throw contextError("worktree ownership cannot be verified", "worktree-owner-unverifiable");
|
|
80
88
|
const prior = await readContextRecord(header, options);
|
|
81
|
-
await writeClaim(header, selected,
|
|
89
|
+
await writeClaim(header, selected, owner, processIdentityOf(runtime), options);
|
|
82
90
|
try {
|
|
83
91
|
await writeContextRecord(header, startup, selected, options);
|
|
84
92
|
}
|
|
85
93
|
catch (error) {
|
|
86
94
|
if (status !== "current")
|
|
87
|
-
await removeMatchingClaim(selected,
|
|
95
|
+
await removeMatchingClaim(selected, owner, options);
|
|
88
96
|
throw error;
|
|
89
97
|
}
|
|
90
|
-
await releaseClaimsOwnedBy(
|
|
98
|
+
await releaseClaimsOwnedBy(owner, selected.gitDir, options);
|
|
91
99
|
if (prior !== null && !samePath(prior.worktreeGitDir, selected.gitDir)) {
|
|
92
|
-
await removeClaimByGitDir(prior.worktreeCommonDir, prior.worktreeGitDir,
|
|
100
|
+
await removeClaimByGitDir(prior.worktreeCommonDir, prior.worktreeGitDir, owner, options);
|
|
93
101
|
}
|
|
94
102
|
return { cwd: selected.root, branch: requireBranch(selected) };
|
|
95
103
|
});
|
|
@@ -101,11 +109,11 @@ export async function clearSessionRepositoryContext(identity, options = {}) {
|
|
|
101
109
|
await rm(contextRecordPath(header, options), { force: true });
|
|
102
110
|
return;
|
|
103
111
|
}
|
|
104
|
-
const
|
|
112
|
+
const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
|
|
105
113
|
await withClaimLock(options, async () => {
|
|
106
|
-
await requireLiveRuntime(header,
|
|
114
|
+
await requireLiveRuntime(header, owner, options);
|
|
107
115
|
await rm(contextRecordPath(header, options), { force: true });
|
|
108
|
-
await releaseClaimsOwnedBy(
|
|
116
|
+
await releaseClaimsOwnedBy(owner, null, options);
|
|
109
117
|
});
|
|
110
118
|
}
|
|
111
119
|
/** Read and revalidate a persisted association; malformed or stale state fails closed to absence. */
|
|
@@ -126,8 +134,8 @@ export async function readSessionRepositoryContext(identity, options = {}) {
|
|
|
126
134
|
/** List same-repository worktrees without acquiring, releasing, or repairing a claim. */
|
|
127
135
|
export async function listSessionRepositoryWorktrees(identity, options = {}) {
|
|
128
136
|
const header = await readSessionHeader(identity);
|
|
129
|
-
const
|
|
130
|
-
await requireLiveRuntime(header,
|
|
137
|
+
const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
|
|
138
|
+
await requireLiveRuntime(header, owner, options);
|
|
131
139
|
const startup = await readGitWorktreeIdentity(header.cwd, false, options.signal);
|
|
132
140
|
const worktrees = await listGitWorktrees(startup.root, startup.commonDir, options.signal);
|
|
133
141
|
if (worktrees.length > MAX_WORKTREES)
|
|
@@ -136,19 +144,17 @@ export async function listSessionRepositoryWorktrees(identity, options = {}) {
|
|
|
136
144
|
const entries = [];
|
|
137
145
|
for (const selected of worktrees) {
|
|
138
146
|
if (selected.branch === null) {
|
|
139
|
-
entries.push({ cwd: selected.root, branch: "(detached)", status: "unverifiable" });
|
|
147
|
+
entries.push({ cwd: selected.root, branch: "(detached)", status: "unverifiable", agentId: null });
|
|
140
148
|
continue;
|
|
141
149
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
status: contended ? "unverifiable" : await claimAvailability(selected, runtimeId, options),
|
|
146
|
-
});
|
|
150
|
+
const status = contended ? "unverifiable" : await claimAvailability(selected, owner, options);
|
|
151
|
+
const claim = status === "current" || status === "busy" ? await readClaim(selected, options) : null;
|
|
152
|
+
entries.push({ cwd: selected.root, branch: selected.branch, status, agentId: claim?.agentId ?? null });
|
|
147
153
|
}
|
|
148
154
|
return entries.sort((left, right) => pathKey(left.cwd).localeCompare(pathKey(right.cwd)));
|
|
149
155
|
}
|
|
150
|
-
async function requireLiveRuntime(header,
|
|
151
|
-
const runtime = await readRuntimeRecord(
|
|
156
|
+
async function requireLiveRuntime(header, owner, options) {
|
|
157
|
+
const runtime = await readRuntimeRecord(owner, options);
|
|
152
158
|
if (runtime === null || runtime.sessionId !== header.id || runtime.sessionFile !== header.file) {
|
|
153
159
|
throw contextError("active session runtime is unavailable", "session-runtime-unavailable");
|
|
154
160
|
}
|
|
@@ -165,14 +171,15 @@ async function requireLiveRuntime(header, runtimeId, options) {
|
|
|
165
171
|
}
|
|
166
172
|
return runtime;
|
|
167
173
|
}
|
|
168
|
-
async function claimAvailability(selected,
|
|
174
|
+
async function claimAvailability(selected, owner, options) {
|
|
169
175
|
const claim = await readClaim(selected, options);
|
|
170
176
|
if (claim === undefined)
|
|
171
177
|
return "available";
|
|
172
178
|
if (claim === null || !claimMatchesWorktree(claim, selected))
|
|
173
179
|
return "unverifiable";
|
|
174
|
-
if (claim
|
|
180
|
+
if (ownedBy(claim, owner))
|
|
175
181
|
return "current";
|
|
182
|
+
// Invariant: a sibling agent of this runtime is live by construction, so its claim reads busy below.
|
|
176
183
|
const inspection = requireInspector(options);
|
|
177
184
|
let observed;
|
|
178
185
|
try {
|
|
@@ -184,8 +191,8 @@ async function claimAvailability(selected, runtimeId, options) {
|
|
|
184
191
|
const live = observed !== null && observed.pid === claim.pid && observed.startIdentity === claim.startIdentity;
|
|
185
192
|
return live ? "busy" : "available";
|
|
186
193
|
}
|
|
187
|
-
async function writeRuntimeRecord(header, runtimeId, runtimeIdentity, options) {
|
|
188
|
-
const existing = await readRuntimeRecord(runtimeId, options);
|
|
194
|
+
async function writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, options) {
|
|
195
|
+
const existing = await readRuntimeRecord({ runtimeId, agentId }, options);
|
|
189
196
|
if (existing !== null && (existing.pid !== runtimeIdentity.pid || existing.startIdentity !== runtimeIdentity.startIdentity)) {
|
|
190
197
|
const inspection = requireInspector(options);
|
|
191
198
|
let observed;
|
|
@@ -201,14 +208,16 @@ async function writeRuntimeRecord(header, runtimeId, runtimeIdentity, options) {
|
|
|
201
208
|
}
|
|
202
209
|
const record = {
|
|
203
210
|
schema: RUNTIME_SCHEMA,
|
|
211
|
+
formatVersion: RECORD_FORMAT_VERSION,
|
|
204
212
|
runtimeId,
|
|
213
|
+
agentId,
|
|
205
214
|
sessionId: header.id,
|
|
206
215
|
sessionFile: header.file,
|
|
207
216
|
pid: runtimeIdentity.pid,
|
|
208
217
|
startIdentity: runtimeIdentity.startIdentity,
|
|
209
218
|
updatedAt: new Date().toISOString(),
|
|
210
219
|
};
|
|
211
|
-
await atomicWrite(runtimeRecordPath(runtimeId, options), record);
|
|
220
|
+
await atomicWrite(runtimeRecordPath({ runtimeId, agentId }, options), record);
|
|
212
221
|
}
|
|
213
222
|
async function writeContextRecord(header, startup, selected, options) {
|
|
214
223
|
const record = {
|
|
@@ -224,10 +233,12 @@ async function writeContextRecord(header, startup, selected, options) {
|
|
|
224
233
|
};
|
|
225
234
|
await atomicWrite(contextRecordPath(header, options), record);
|
|
226
235
|
}
|
|
227
|
-
async function writeClaim(header, selected,
|
|
236
|
+
async function writeClaim(header, selected, owner, runtimeIdentity, options) {
|
|
228
237
|
const claim = {
|
|
229
238
|
schema: CLAIM_SCHEMA,
|
|
230
|
-
|
|
239
|
+
formatVersion: RECORD_FORMAT_VERSION,
|
|
240
|
+
runtimeId: owner.runtimeId,
|
|
241
|
+
agentId: owner.agentId,
|
|
231
242
|
sessionId: header.id,
|
|
232
243
|
worktreeRoot: selected.root,
|
|
233
244
|
worktreeCommonDir: selected.commonDir,
|
|
@@ -241,8 +252,34 @@ async function writeClaim(header, selected, runtimeId, runtimeIdentity, options)
|
|
|
241
252
|
async function readContextRecord(header, options) {
|
|
242
253
|
return await readJsonRecord(contextRecordPath(header, options), isContextRecord);
|
|
243
254
|
}
|
|
244
|
-
async function readRuntimeRecord(
|
|
245
|
-
|
|
255
|
+
async function readRuntimeRecord(owner, options) {
|
|
256
|
+
const record = await readRuntimeRecordAt(runtimeRecordPath(owner, options));
|
|
257
|
+
return record !== null && ownedBy(record, owner) ? record : null;
|
|
258
|
+
}
|
|
259
|
+
async function readRuntimeRecordAt(path) {
|
|
260
|
+
const stored = await readJsonRecord(path, isStoredRuntimeRecord);
|
|
261
|
+
return stored === null ? null : { ...stored, formatVersion: RECORD_FORMAT_VERSION, agentId: stored.agentId ?? PRIMARY_SESSION_AGENT_ID };
|
|
262
|
+
}
|
|
263
|
+
/** Every agent's runtime record of one runtime, with the file that holds it. */
|
|
264
|
+
async function readRuntimeRecordsOf(runtimeId, options) {
|
|
265
|
+
const directory = runtimesDirectory(options);
|
|
266
|
+
const names = await readdir(directory).catch(() => []);
|
|
267
|
+
if (names.length > MAX_WORKTREES * 4)
|
|
268
|
+
throw contextError("session runtime store exceeds its bounded limit", "session-runtime-limit");
|
|
269
|
+
const records = [];
|
|
270
|
+
for (const name of names) {
|
|
271
|
+
if (!name.endsWith(".json"))
|
|
272
|
+
continue;
|
|
273
|
+
const path = join(directory, name);
|
|
274
|
+
const record = await readRuntimeRecordAt(path);
|
|
275
|
+
if (record?.runtimeId === runtimeId)
|
|
276
|
+
records.push({ path, record });
|
|
277
|
+
}
|
|
278
|
+
return records;
|
|
279
|
+
}
|
|
280
|
+
async function readClaimRecordAt(path) {
|
|
281
|
+
const stored = await readJsonRecord(path, isStoredClaimRecord);
|
|
282
|
+
return stored === null ? null : normalizeClaim(stored);
|
|
246
283
|
}
|
|
247
284
|
/** Undefined means absent; null means present but invalid. */
|
|
248
285
|
async function readClaim(selected, options) {
|
|
@@ -252,7 +289,7 @@ async function readClaim(selected, options) {
|
|
|
252
289
|
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
|
|
253
290
|
return null;
|
|
254
291
|
const value = JSON.parse((await readFile(path, { signal: options.signal })).toString("utf8"));
|
|
255
|
-
return
|
|
292
|
+
return isStoredClaimRecord(value) ? normalizeClaim(value) : null;
|
|
256
293
|
}
|
|
257
294
|
catch (error) {
|
|
258
295
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
@@ -274,7 +311,15 @@ async function validateContextRecord(header, record, signal) {
|
|
|
274
311
|
throw new Error("associated worktree identity changed");
|
|
275
312
|
return selected;
|
|
276
313
|
}
|
|
277
|
-
|
|
314
|
+
/** Release one agent's claims except the one it keeps; sibling agents of the runtime are untouched. */
|
|
315
|
+
async function releaseClaimsOwnedBy(owner, keepGitDir, options) {
|
|
316
|
+
await releaseClaimsWhere(claim => ownedBy(claim, owner) && (keepGitDir === null || !samePath(claim.worktreeGitDir, keepGitDir)), options);
|
|
317
|
+
}
|
|
318
|
+
/** Release every agent's claims of one runtime; only process exit does this. */
|
|
319
|
+
async function releaseRuntimeClaims(runtimeId, options) {
|
|
320
|
+
await releaseClaimsWhere(claim => claim.runtimeId === runtimeId, options);
|
|
321
|
+
}
|
|
322
|
+
async function releaseClaimsWhere(release, options) {
|
|
278
323
|
const directory = claimsDirectory(options);
|
|
279
324
|
const names = await readdir(directory).catch(() => []);
|
|
280
325
|
if (names.length > MAX_WORKTREES * 4)
|
|
@@ -283,19 +328,18 @@ async function releaseClaimsOwnedBy(runtimeId, keepGitDir, options) {
|
|
|
283
328
|
if (!name.endsWith(".json"))
|
|
284
329
|
continue;
|
|
285
330
|
const path = join(directory, name);
|
|
286
|
-
const claim = await
|
|
287
|
-
if (claim
|
|
331
|
+
const claim = await readClaimRecordAt(path);
|
|
332
|
+
if (claim !== null && release(claim))
|
|
288
333
|
await rm(path, { force: true });
|
|
289
|
-
}
|
|
290
334
|
}
|
|
291
335
|
}
|
|
292
|
-
async function removeMatchingClaim(selected,
|
|
293
|
-
await removeClaimByGitDir(selected.commonDir, selected.gitDir,
|
|
336
|
+
async function removeMatchingClaim(selected, owner, options) {
|
|
337
|
+
await removeClaimByGitDir(selected.commonDir, selected.gitDir, owner, options);
|
|
294
338
|
}
|
|
295
|
-
async function removeClaimByGitDir(commonDir, gitDir,
|
|
339
|
+
async function removeClaimByGitDir(commonDir, gitDir, owner, options) {
|
|
296
340
|
const path = claimRecordPath(commonDir, gitDir, options);
|
|
297
|
-
const claim = await
|
|
298
|
-
if (claim
|
|
341
|
+
const claim = await readClaimRecordAt(path);
|
|
342
|
+
if (claim !== null && ownedBy(claim, owner))
|
|
299
343
|
await rm(path, { force: true });
|
|
300
344
|
}
|
|
301
345
|
async function readSessionHeader(identity) {
|
|
@@ -401,21 +445,30 @@ async function withClaimLock(options, operation) {
|
|
|
401
445
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
402
446
|
const path = join(directory, "mutation.lock");
|
|
403
447
|
const deadline = Date.now() + (options.lockTimeoutMs ?? LOCK_TIMEOUT_MS);
|
|
448
|
+
const holder = await lockHolderIdentity(options);
|
|
404
449
|
let handle;
|
|
405
|
-
|
|
450
|
+
for (let attempt = 0; handle === undefined; attempt++) {
|
|
406
451
|
try {
|
|
407
452
|
handle = await open(path, "wx", 0o600);
|
|
408
453
|
}
|
|
409
454
|
catch (error) {
|
|
410
455
|
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
411
456
|
throw error;
|
|
457
|
+
if (attempt === 0 && await evictDeadLockHolder(path, options))
|
|
458
|
+
continue;
|
|
412
459
|
if (Date.now() >= deadline)
|
|
413
460
|
throw contextError("worktree claim mutation is busy", "worktree-claim-busy");
|
|
414
461
|
await new Promise(resolvePromise => setTimeout(resolvePromise, LOCK_RETRY_MS));
|
|
415
462
|
}
|
|
416
463
|
}
|
|
417
464
|
try {
|
|
418
|
-
|
|
465
|
+
const record = {
|
|
466
|
+
pid: process.pid,
|
|
467
|
+
...(holder === null ? {} : { startIdentity: holder.startIdentity }),
|
|
468
|
+
acquiredAt: new Date().toISOString(),
|
|
469
|
+
nonce: randomUUID(),
|
|
470
|
+
};
|
|
471
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`);
|
|
419
472
|
return await operation();
|
|
420
473
|
}
|
|
421
474
|
finally {
|
|
@@ -423,6 +476,59 @@ async function withClaimLock(options, operation) {
|
|
|
423
476
|
await rm(path, { force: true }).catch(() => undefined);
|
|
424
477
|
}
|
|
425
478
|
}
|
|
479
|
+
const lockHolderIdentities = new WeakMap();
|
|
480
|
+
/** This process's identity as recorded in the lock; null leaves the lock unevictable, never wrongly evictable. */
|
|
481
|
+
async function lockHolderIdentity(options) {
|
|
482
|
+
if (options.runtimeIdentity?.pid === process.pid)
|
|
483
|
+
return options.runtimeIdentity;
|
|
484
|
+
const inspection = options.inspectProcess;
|
|
485
|
+
if (inspection === undefined)
|
|
486
|
+
return null;
|
|
487
|
+
let identity = lockHolderIdentities.get(inspection);
|
|
488
|
+
if (identity === undefined) {
|
|
489
|
+
identity = inspection(process.pid)
|
|
490
|
+
.then(observed => observed !== null && observed.pid === process.pid ? observed : null)
|
|
491
|
+
.catch(() => null);
|
|
492
|
+
lockHolderIdentities.set(inspection, identity);
|
|
493
|
+
}
|
|
494
|
+
return await identity;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Evict the mutation lock only when its recorded holder is proven dead: the pid is gone or now names a process
|
|
498
|
+
* with another start identity. A lock without a holder identity, or a holder that cannot be inspected, is kept.
|
|
499
|
+
*/
|
|
500
|
+
async function evictDeadLockHolder(path, options) {
|
|
501
|
+
const inspection = options.inspectProcess;
|
|
502
|
+
if (inspection === undefined)
|
|
503
|
+
return false;
|
|
504
|
+
const holder = await readJsonRecord(path, isMutationLockRecord);
|
|
505
|
+
if (holder === null || holder.startIdentity === undefined || holder.pid === process.pid)
|
|
506
|
+
return false;
|
|
507
|
+
let observed;
|
|
508
|
+
try {
|
|
509
|
+
observed = await inspection(holder.pid);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
if (observed !== null && observed.pid === holder.pid && observed.startIdentity === holder.startIdentity)
|
|
515
|
+
return false;
|
|
516
|
+
const evicted = `${path}.${randomUUID()}.evicted`;
|
|
517
|
+
try {
|
|
518
|
+
await rename(path, evicted);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
const moved = await readJsonRecord(evicted, isMutationLockRecord);
|
|
524
|
+
if (moved?.nonce !== holder.nonce) {
|
|
525
|
+
// Concurrency: another waiter evicted first and a live holder took the lock since; hand it back untouched.
|
|
526
|
+
await rename(evicted, path).catch(() => undefined);
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
await rm(evicted, { force: true });
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
426
532
|
async function atomicWrite(path, value) {
|
|
427
533
|
const directory = dirname(path);
|
|
428
534
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -455,8 +561,13 @@ function contextRecordPath(header, options) {
|
|
|
455
561
|
function claimStoreRoot(options) {
|
|
456
562
|
return join(options.dataDir ?? resolveProductPaths().dataDir, PRODUCT_IDENTITY.state.sessionWorktreeClaimDirectory);
|
|
457
563
|
}
|
|
458
|
-
function
|
|
459
|
-
return join(claimStoreRoot(options), "runtimes"
|
|
564
|
+
function runtimesDirectory(options) {
|
|
565
|
+
return join(claimStoreRoot(options), "runtimes");
|
|
566
|
+
}
|
|
567
|
+
// Compatibility: the primary agent keeps the single-agent file name, so a version-1 runtime record is its record.
|
|
568
|
+
function runtimeRecordPath(owner, options) {
|
|
569
|
+
const key = owner.agentId === PRIMARY_SESSION_AGENT_ID ? owner.runtimeId : `${owner.runtimeId}\0${owner.agentId}`;
|
|
570
|
+
return join(runtimesDirectory(options), `${digest(key)}.json`);
|
|
460
571
|
}
|
|
461
572
|
function claimsDirectory(options) {
|
|
462
573
|
return join(claimStoreRoot(options), "worktrees");
|
|
@@ -472,6 +583,15 @@ function requireRuntimeId(options) {
|
|
|
472
583
|
assertRuntimeId(runtimeId);
|
|
473
584
|
return runtimeId;
|
|
474
585
|
}
|
|
586
|
+
function requireAgentId(options) {
|
|
587
|
+
const agentId = options.agentId ?? PRIMARY_SESSION_AGENT_ID;
|
|
588
|
+
if (!validSessionId(agentId))
|
|
589
|
+
throw contextError("session agent identity is invalid", "session-agent-invalid");
|
|
590
|
+
return agentId;
|
|
591
|
+
}
|
|
592
|
+
function ownedBy(record, owner) {
|
|
593
|
+
return record.runtimeId === owner.runtimeId && record.agentId === owner.agentId;
|
|
594
|
+
}
|
|
475
595
|
function requireInspector(options) {
|
|
476
596
|
if (options.inspectProcess === undefined)
|
|
477
597
|
throw contextError("native process identity inspection is unavailable", "process-inspection-unavailable");
|
|
@@ -520,19 +640,34 @@ function isContextRecord(value) {
|
|
|
520
640
|
&& validPath(value.worktreeRoot) && validPath(value.worktreeCommonDir) && validPath(value.worktreeGitDir)
|
|
521
641
|
&& typeof value.updatedAt === "string";
|
|
522
642
|
}
|
|
523
|
-
function
|
|
524
|
-
return isRecord(value) && value.schema === RUNTIME_SCHEMA &&
|
|
525
|
-
&& validPath(value.sessionFile) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
|
|
643
|
+
function isStoredRuntimeRecord(value) {
|
|
644
|
+
return isRecord(value) && value.schema === RUNTIME_SCHEMA && validRecordVersion(value) && validSessionId(value.runtimeId)
|
|
645
|
+
&& validSessionId(value.sessionId) && validPath(value.sessionFile) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
|
|
526
646
|
&& typeof value.startIdentity === "string" && value.startIdentity.length > 0 && value.startIdentity.length <= 512
|
|
527
647
|
&& !value.startIdentity.includes("\0") && typeof value.updatedAt === "string";
|
|
528
648
|
}
|
|
529
|
-
function
|
|
530
|
-
return isRecord(value) && value.schema === CLAIM_SCHEMA &&
|
|
649
|
+
function isStoredClaimRecord(value) {
|
|
650
|
+
return isRecord(value) && value.schema === CLAIM_SCHEMA && validRecordVersion(value) && validSessionId(value.runtimeId)
|
|
651
|
+
&& validSessionId(value.sessionId)
|
|
531
652
|
&& validPath(value.worktreeRoot) && validPath(value.worktreeCommonDir) && validPath(value.worktreeGitDir)
|
|
532
653
|
&& Number.isSafeInteger(value.pid) && Number(value.pid) > 0
|
|
533
654
|
&& typeof value.startIdentity === "string" && value.startIdentity.length > 0 && value.startIdentity.length <= 512
|
|
534
655
|
&& !value.startIdentity.includes("\0") && typeof value.updatedAt === "string";
|
|
535
656
|
}
|
|
657
|
+
function isMutationLockRecord(value) {
|
|
658
|
+
return isRecord(value) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
|
|
659
|
+
&& (value.startIdentity === undefined || (typeof value.startIdentity === "string" && value.startIdentity.length > 0
|
|
660
|
+
&& value.startIdentity.length <= 512 && !value.startIdentity.includes("\0")))
|
|
661
|
+
&& typeof value.acquiredAt === "string" && typeof value.nonce === "string" && value.nonce.length > 0;
|
|
662
|
+
}
|
|
663
|
+
function validRecordVersion(value) {
|
|
664
|
+
if (value.formatVersion === undefined)
|
|
665
|
+
return value.agentId === undefined;
|
|
666
|
+
return value.formatVersion === RECORD_FORMAT_VERSION && validSessionId(value.agentId);
|
|
667
|
+
}
|
|
668
|
+
function normalizeClaim(stored) {
|
|
669
|
+
return { ...stored, formatVersion: RECORD_FORMAT_VERSION, agentId: stored.agentId ?? PRIMARY_SESSION_AGENT_ID };
|
|
670
|
+
}
|
|
536
671
|
function isRecord(value) {
|
|
537
672
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
538
673
|
}
|
|
@@ -402,9 +402,12 @@ async function acquireLock(path) {
|
|
|
402
402
|
return lock;
|
|
403
403
|
}
|
|
404
404
|
catch (error) {
|
|
405
|
-
|
|
405
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
406
|
+
// Platform: Windows reports a lock file another holder is still deleting as EPERM or EACCES.
|
|
407
|
+
const releasing = process.platform === "win32" && (code === "EPERM" || code === "EACCES");
|
|
408
|
+
if (code !== "EEXIST" && !releasing)
|
|
406
409
|
throw error;
|
|
407
|
-
if (await reclaimAbandonedLock(path))
|
|
410
|
+
if (code === "EEXIST" && await reclaimAbandonedLock(path))
|
|
408
411
|
continue;
|
|
409
412
|
if (Date.now() >= deadline)
|
|
410
413
|
throw error;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { PiTuiTerminalPort } from "./contracts.js";
|
|
2
2
|
export declare const PINNED_PI_TUI_DAMAGE_GRAMMAR = "@earendil-works/pi-tui@1.1.0:tui-alt-screen-one-write-v1";
|
|
3
3
|
export interface PiTuiDamageFrameDescriptor {
|
|
4
|
+
/**
|
|
5
|
+
* Frame ids are compared only within one presentation epoch; absent means the current epoch. A
|
|
6
|
+
* presenter switch starts a new epoch, because each presenter numbers its own frames.
|
|
7
|
+
*/
|
|
8
|
+
readonly epoch?: number;
|
|
4
9
|
readonly frameId: number;
|
|
5
10
|
readonly width: number;
|
|
6
11
|
readonly height: number;
|
|
@@ -64,6 +69,12 @@ export declare class DamageAwareTerminalAdapter implements PiTuiTerminalPort {
|
|
|
64
69
|
get lastDecision(): PiTuiDamageDecision;
|
|
65
70
|
get hyperlinkCleanupPending(): boolean;
|
|
66
71
|
get canvasBackgroundAnsi(): string | null;
|
|
72
|
+
get presentationEpoch(): number;
|
|
73
|
+
/**
|
|
74
|
+
* Starts a new presentation epoch: the remembered cells and the armed frame are discarded and frame ids
|
|
75
|
+
* restart, so the next frame is painted in full rather than reconciled against another presenter's.
|
|
76
|
+
*/
|
|
77
|
+
invalidatePresentation(): void;
|
|
67
78
|
/** Invalidates remembered terminal cells before the shell forces one complete canvas repaint. */
|
|
68
79
|
setCanvasBackground(ansi: string | null): boolean;
|
|
69
80
|
/**
|
|
@@ -23,6 +23,7 @@ export class DamageAwareTerminalAdapter {
|
|
|
23
23
|
#cleanedRevision = 0;
|
|
24
24
|
#recoveryRevision = 0;
|
|
25
25
|
#epoch = 0;
|
|
26
|
+
#presentationEpoch = 0;
|
|
26
27
|
#armed;
|
|
27
28
|
#lastConsumedFrameId = 0;
|
|
28
29
|
#cacheWidth = 0;
|
|
@@ -44,6 +45,17 @@ export class DamageAwareTerminalAdapter {
|
|
|
44
45
|
get lastDecision() { return this.#decision; }
|
|
45
46
|
get hyperlinkCleanupPending() { return this.#cleanupRevision > this.#cleanedRevision; }
|
|
46
47
|
get canvasBackgroundAnsi() { return this.#canvasBackgroundAnsi; }
|
|
48
|
+
get presentationEpoch() { return this.#presentationEpoch; }
|
|
49
|
+
/**
|
|
50
|
+
* Starts a new presentation epoch: the remembered cells and the armed frame are discarded and frame ids
|
|
51
|
+
* restart, so the next frame is painted in full rather than reconciled against another presenter's.
|
|
52
|
+
*/
|
|
53
|
+
invalidatePresentation() {
|
|
54
|
+
this.#presentationEpoch += 1;
|
|
55
|
+
this.#armed = undefined;
|
|
56
|
+
this.#lastConsumedFrameId = 0;
|
|
57
|
+
this.#invalidatePresentation();
|
|
58
|
+
}
|
|
47
59
|
/** Invalidates remembered terminal cells before the shell forces one complete canvas repaint. */
|
|
48
60
|
setCanvasBackground(ansi) {
|
|
49
61
|
if (this.#canvasBackgroundAnsi === ansi)
|
|
@@ -108,7 +120,7 @@ export class DamageAwareTerminalAdapter {
|
|
|
108
120
|
const armed = this.#armed;
|
|
109
121
|
this.#armed = undefined;
|
|
110
122
|
const parsed = parsePinnedFullscreenWrite(data);
|
|
111
|
-
if (armed !== undefined && armed.descriptor
|
|
123
|
+
if (armed !== undefined && this.#isStale(armed.descriptor)) {
|
|
112
124
|
// Concurrency: a recognized obsolete frame must never repaint newer cells,
|
|
113
125
|
// acknowledge cleanup, or become the reference for a subsequent differential.
|
|
114
126
|
this.#decision = this.#decide(armed, parsed);
|
|
@@ -189,7 +201,7 @@ export class DamageAwareTerminalAdapter {
|
|
|
189
201
|
shiftRows: descriptor.verticalShiftRows,
|
|
190
202
|
paintedRows: [],
|
|
191
203
|
};
|
|
192
|
-
if (
|
|
204
|
+
if (this.#isStale(descriptor))
|
|
193
205
|
return { ...base, reason: "stale-frame" };
|
|
194
206
|
this.#lastConsumedFrameId = descriptor.frameId;
|
|
195
207
|
if (this.hyperlinkCleanupPending)
|
|
@@ -265,6 +277,10 @@ export class DamageAwareTerminalAdapter {
|
|
|
265
277
|
paintedRows: [...painted].sort((left, right) => left - right),
|
|
266
278
|
};
|
|
267
279
|
}
|
|
280
|
+
#isStale(descriptor) {
|
|
281
|
+
return (descriptor.epoch ?? this.#presentationEpoch) !== this.#presentationEpoch
|
|
282
|
+
|| descriptor.frameId <= this.#lastConsumedFrameId;
|
|
283
|
+
}
|
|
268
284
|
#rememberFrame(descriptor, parsed, raw) {
|
|
269
285
|
if (raw.includes("\u001b[2J") || raw.includes("\u001b[?1049") || raw.includes("\u001b_G") || raw.includes("\u001bPq")) {
|
|
270
286
|
this.#invalidateRows();
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "darwin",
|
|
6
6
|
"architecture": "arm64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-10-
|
|
8
|
+
"builtAt": "2026-10-10T17:42:20.005Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "51b0b2dc1c9ae577f2586c4217066929278726c2c15308d9144eb6f538e118ff",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "linux",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-10-
|
|
8
|
+
"builtAt": "2026-10-10T17:42:10.255Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "ef62679e83bc1fad3d38f9f21675d9256f08d20e67fd4791571032115ca1b0e9",
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
"platform": "win32",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-10-
|
|
8
|
+
"builtAt": "2026-10-10T17:42:54.136Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian.exe",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "bdafa323144718cc73b6e5a970608fd58420ec343f6fa7fac76aeb216edae485",
|
|
12
12
|
"size": 288256
|
|
13
13
|
},
|
|
14
14
|
"provenance": {
|
|
Binary file
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { UiTheme } from "./theme.js";
|
|
2
2
|
/**
|
|
3
3
|
* The menu a value opens: anchored to the row it was opened from, marking what
|
|
4
|
-
* is in effect, highlighting nothing until something is picked, and
|
|
5
|
-
*
|
|
4
|
+
* is in effect, highlighting nothing until something is picked, and laid over
|
|
5
|
+
* its anchor so the value in effect stays where it was read.
|
|
6
6
|
*/
|
|
7
7
|
export interface ValueMenuState {
|
|
8
8
|
/** Values offered, as they should read. */
|
|
@@ -37,7 +37,10 @@ export interface ValueMenuLayout {
|
|
|
37
37
|
/** Columns reserved at the right edge, such as a scrollbar rail. */
|
|
38
38
|
readonly reservedRight: number;
|
|
39
39
|
}
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Where the menu is placed: over its anchor, so the value in effect sits on the very
|
|
42
|
+
* row it was read from, shifted only as far as the body edges require.
|
|
43
|
+
*/
|
|
41
44
|
export declare function valueMenuFrame(state: ValueMenuState, anchor: ValueMenuAnchor, layout: ValueMenuLayout): ValueMenuFrame;
|
|
42
45
|
/** Draws the menu over the rows behind it, leaving them otherwise untouched. */
|
|
43
46
|
export declare function renderValueMenu(lines: readonly string[], state: ValueMenuState, frame: ValueMenuFrame, theme: UiTheme): readonly string[];
|