@zq-silk/yui 2.1.0 → 2.3.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.
Files changed (135) hide show
  1. package/README.md +8 -2
  2. package/dist/cli/commandCatalog.js +32 -6
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli/interactionPolicy.js +4 -0
  5. package/dist/cli.js +23 -3078
  6. package/dist/commands/taskCommands.js +84 -28
  7. package/dist/commands/taskIntegrationCommands.js +8 -17
  8. package/dist/commands/taskUpstreamCommands.js +3 -1
  9. package/dist/context/runContextPack.js +132 -5
  10. package/dist/context/sourceRunContext.js +4 -2
  11. package/dist/context/taskContext.js +6 -1
  12. package/dist/controlPlaneCli.js +3099 -0
  13. package/dist/controller/clientRuntime.js +3 -2
  14. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  15. package/dist/controller/jobControl.js +21 -0
  16. package/dist/executor/agentExecutor.js +1 -1
  17. package/dist/executor/effectiveLaunch.js +18 -5
  18. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  19. package/dist/integration/gitIntegrationService.js +58 -82
  20. package/dist/job/jobCleanupInspection.js +67 -0
  21. package/dist/message/messageContinuation.js +7 -2
  22. package/dist/nativeAgent/agent.js +176 -74
  23. package/dist/nativeAgent/cliDemo.js +37 -0
  24. package/dist/nativeAgent/codingTools.js +11 -0
  25. package/dist/nativeAgent/commandTool.js +215 -0
  26. package/dist/nativeAgent/compactionDemo.js +158 -0
  27. package/dist/nativeAgent/composition.js +71 -0
  28. package/dist/nativeAgent/context/budget.js +44 -0
  29. package/dist/nativeAgent/context/index.js +339 -0
  30. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  31. package/dist/nativeAgent/demo.js +12 -1
  32. package/dist/nativeAgent/evaluation/cases.js +38 -0
  33. package/dist/nativeAgent/evaluation/checks.js +91 -0
  34. package/dist/nativeAgent/evaluation/demo.js +19 -0
  35. package/dist/nativeAgent/evaluation/files.js +54 -0
  36. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  37. package/dist/nativeAgent/evaluation/index.js +239 -0
  38. package/dist/nativeAgent/executionOwner.js +209 -0
  39. package/dist/nativeAgent/filePatterns.js +170 -0
  40. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  41. package/dist/nativeAgent/index.js +13 -0
  42. package/dist/nativeAgent/interaction/cli.js +358 -0
  43. package/dist/nativeAgent/interaction/contracts.js +1 -0
  44. package/dist/nativeAgent/interaction/index.js +3 -0
  45. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  46. package/dist/nativeAgent/interaction/renderer.js +34 -0
  47. package/dist/nativeAgent/localSafety.js +258 -0
  48. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  49. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  50. package/dist/nativeAgent/model/errors.js +47 -0
  51. package/dist/nativeAgent/model/gateway.js +423 -0
  52. package/dist/nativeAgent/model/index.js +7 -0
  53. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  54. package/dist/nativeAgent/model/protocols.js +19 -0
  55. package/dist/nativeAgent/model/responses.js +263 -0
  56. package/dist/nativeAgent/model/types.js +1 -0
  57. package/dist/nativeAgent/model/wire.js +73 -0
  58. package/dist/nativeAgent/observability/index.js +220 -0
  59. package/dist/nativeAgent/product/catalog.js +82 -0
  60. package/dist/nativeAgent/product/config.js +295 -0
  61. package/dist/nativeAgent/product/facts.js +30 -0
  62. package/dist/nativeAgent/product/index.js +62 -0
  63. package/dist/nativeAgent/product/location.js +44 -0
  64. package/dist/nativeAgent/product/runtime.js +276 -0
  65. package/dist/nativeAgent/product/storage.js +49 -0
  66. package/dist/nativeAgent/product/tools.js +47 -0
  67. package/dist/nativeAgent/product/transport.js +54 -0
  68. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  69. package/dist/nativeAgent/searchTools.js +305 -0
  70. package/dist/nativeAgent/session/backends.js +293 -0
  71. package/dist/nativeAgent/session/catalog.js +97 -0
  72. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  73. package/dist/nativeAgent/session/contracts.js +1 -0
  74. package/dist/nativeAgent/session/format.js +269 -0
  75. package/dist/nativeAgent/session/index.js +5 -0
  76. package/dist/nativeAgent/session/location.js +36 -0
  77. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  78. package/dist/nativeAgent/session/store.js +248 -0
  79. package/dist/nativeAgent/textTools.js +270 -133
  80. package/dist/nativeAgent/toolManager/executor.js +290 -0
  81. package/dist/nativeAgent/toolManager/index.js +4 -0
  82. package/dist/nativeAgent/validation.js +2 -2
  83. package/dist/repository/gitWorkspace.js +41 -12
  84. package/dist/repository/taskWorkspaceCoordinator.js +72 -145
  85. package/dist/repository/taskWorkspacePreparer.js +39 -43
  86. package/dist/repository/workspaceCleanupInspection.js +88 -7
  87. package/dist/resources/resourceRegistrar.js +16 -10
  88. package/dist/runtime/integrationRuntimeIsolation.js +21 -0
  89. package/dist/runtime/taskRuntimeIsolation.js +91 -21
  90. package/dist/task/archiveDiagnostics.js +26 -8
  91. package/dist/task/archivePreflight.js +26 -9
  92. package/dist/task/leaderArchive.js +9 -15
  93. package/dist/task/taskAuthority.js +56 -0
  94. package/dist/web/assets/client/api.js +8 -0
  95. package/dist/web/assets/client/app.js +74 -9
  96. package/dist/web/assets/client/components.js +95 -36
  97. package/dist/web/assets/client/detail.js +110 -64
  98. package/dist/web/assets/client/dock.js +95 -11
  99. package/dist/web/assets/client/evidence.js +286 -94
  100. package/dist/web/assets/client/i18n.js +182 -62
  101. package/dist/web/assets/client/overview.js +9 -10
  102. package/dist/web/assets/client/records.js +142 -7
  103. package/dist/web/assets/client/sections.js +234 -198
  104. package/dist/web/assets/client/sidebar.js +31 -2
  105. package/dist/web/assets/icons.js +4 -0
  106. package/dist/web/assets/shell.js +2 -1
  107. package/dist/web/assets/styles/components.js +64 -15
  108. package/dist/web/assets/styles/layout.js +11 -4
  109. package/dist/web/assets/styles/responsive.js +16 -6
  110. package/dist/web/assets/styles/tokens.js +1 -1
  111. package/dist/web/assets/styles/views.js +62 -42
  112. package/dist/web/webServer.js +12 -1
  113. package/dist/web/webTaskSurface.js +8 -1
  114. package/docs/agent-result-consumption.md +16 -0
  115. package/docs/agent-result-consumption.zh-CN.md +13 -0
  116. package/docs/examples/agent-offline.mjs +194 -0
  117. package/docs/native-agent.md +283 -0
  118. package/docs/observability/README.md +13 -3
  119. package/docs/observability/README.zh-CN.md +10 -3
  120. package/docs/release-workflow.md +72 -9
  121. package/docs/release-workflow.zh-CN.md +55 -6
  122. package/docs/roles-and-configuration.md +32 -0
  123. package/docs/roles-and-configuration.zh-CN.md +26 -0
  124. package/docs/task-delivery.md +36 -8
  125. package/docs/task-delivery.zh-CN.md +24 -7
  126. package/i18n/README.zh-CN.md +3 -2
  127. package/package.json +1 -1
  128. package/skills/yui-leader/SKILL.md +9 -0
  129. package/skills/yui-leader/references/execution.md +2 -1
  130. package/skills/yui-leader/references/integration.md +20 -0
  131. package/skills/yui-operator/SKILL.md +4 -0
  132. package/skills/yui-reviewer/SKILL.md +8 -0
  133. package/skills/yui-runtime/SKILL.md +11 -0
  134. package/skills/yui-runtime/references/publication.md +20 -4
  135. package/skills/yui-worker/SKILL.md +3 -0
@@ -0,0 +1,290 @@
1
+ // Match the existing model/tool wire budgets; all sizes are UTF-8 JSON bytes.
2
+ const messageBytes = 512 * 1024;
3
+ const argumentBytes = 64 * 1024;
4
+ const callsPerBatch = 8;
5
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
6
+ && Object.getPrototypeOf(value) === Object.prototype;
7
+ const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
8
+ const identifier = (value) => nonempty(value) && Buffer.byteLength(value, 'utf8') <= 1024;
9
+ const bytes = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8');
10
+ function json(value, depth = 0) {
11
+ if (depth > 32)
12
+ return false;
13
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
14
+ return true;
15
+ if (typeof value === 'number')
16
+ return Number.isFinite(value);
17
+ if (Array.isArray(value))
18
+ return Array.from(value).every(v => json(v, depth + 1));
19
+ return object(value) && Object.values(value).every(v => json(v, depth + 1));
20
+ }
21
+ function freeze(value) {
22
+ if (value && typeof value === 'object') {
23
+ for (const child of Object.values(value))
24
+ freeze(child);
25
+ Object.freeze(value);
26
+ }
27
+ return value;
28
+ }
29
+ function snapshot(value, limit) {
30
+ const copy = structuredClone(value);
31
+ if (!json(copy) || bytes(copy) > limit)
32
+ throw new Error('Invalid or oversized JSON');
33
+ return freeze(copy);
34
+ }
35
+ function failure(code, message, effect = 'none') {
36
+ return { ok: false, error: { code, message, effect } };
37
+ }
38
+ function outcome(raw, identity, beforeStart = false) {
39
+ try {
40
+ const value = snapshot(raw, messageBytes);
41
+ if (!object(value))
42
+ throw new Error();
43
+ let normalized;
44
+ if (!beforeStart && value.ok === true && typeof value.content === 'string') {
45
+ normalized = { ok: true, content: value.content };
46
+ }
47
+ else if (value.ok === false && object(value.error) && nonempty(value.error.code)
48
+ && typeof value.error.message === 'string'
49
+ && (value.error.effect === 'none' || (!beforeStart && value.error.effect === 'unknown'))) {
50
+ normalized = { ok: false, error: {
51
+ code: value.error.code, message: value.error.message, effect: value.error.effect,
52
+ } };
53
+ }
54
+ else
55
+ throw new Error();
56
+ // Guarantee the consumer can append the paired message without truncation.
57
+ if (bytes({ role: 'tool', toolCallId: identity.toolCallId, name: identity.name, outcome: normalized }) > messageBytes) {
58
+ throw new Error();
59
+ }
60
+ return normalized;
61
+ }
62
+ catch {
63
+ return failure('tool_protocol', 'Invalid or oversized tool result', beforeStart ? 'none' : 'unknown');
64
+ }
65
+ }
66
+ function status(started, value) {
67
+ if (!started)
68
+ return 'not_executed';
69
+ if (value.ok)
70
+ return 'succeeded';
71
+ if (value.error.effect === 'unknown')
72
+ return 'unknown';
73
+ return value.error.code === 'cancelled' ? 'cancelled' : 'failed';
74
+ }
75
+ export function buildExecutor(options) {
76
+ if (!options?.environment || typeof options.environment.acquire !== 'function') {
77
+ throw new Error('Tool environment.acquire capability is required');
78
+ }
79
+ if (!options.permission || typeof options.permission.check !== 'function') {
80
+ throw new Error('Tool permission.check capability is required');
81
+ }
82
+ if (!Array.isArray(options.tools))
83
+ throw new Error('Tool collection is required');
84
+ const acquire = options.environment.acquire.bind(options.environment);
85
+ const check = options.permission.check.bind(options.permission);
86
+ const registry = new Map();
87
+ for (const tool of options.tools) {
88
+ let definition;
89
+ try {
90
+ definition = snapshot(tool.definition, messageBytes);
91
+ if (!identifier(definition.name) || typeof definition.description !== 'string'
92
+ || !object(definition.inputSchema) || typeof tool.validate !== 'function'
93
+ || typeof tool.execute !== 'function')
94
+ throw new Error();
95
+ }
96
+ catch {
97
+ throw new Error('Invalid tool declaration or implementation');
98
+ }
99
+ if (registry.has(definition.name))
100
+ throw new Error('Tool names must be unique');
101
+ registry.set(definition.name, {
102
+ definition, validate: tool.validate.bind(tool), execute: tool.execute.bind(tool),
103
+ });
104
+ }
105
+ const definitions = snapshot([...registry.values()].map(t => t.definition), messageBytes);
106
+ return Object.freeze({
107
+ definitions,
108
+ async executeBatch(request) {
109
+ // Admit the whole immutable batch before acquiring any resource.
110
+ let scope, calls;
111
+ try {
112
+ scope = snapshot(request.scope, 4096);
113
+ calls = snapshot(request.calls, messageBytes);
114
+ if (!identifier(scope.sessionId) || !identifier(scope.turnId) || !Number.isSafeInteger(scope.step)
115
+ || scope.step < 1 || !Array.isArray(calls) || !calls.length || calls.length > callsPerBatch
116
+ || calls.some(c => !object(c) || !identifier(c.id) || !identifier(c.name)
117
+ || !json(c.arguments) || bytes(c.arguments) > argumentBytes)
118
+ || new Set(calls.map(c => c.id)).size !== calls.length)
119
+ throw new Error();
120
+ }
121
+ catch {
122
+ throw new Error('Invalid tool batch: bounded JSON, scope and unique call identities required');
123
+ }
124
+ // Missing recording/cancellation capabilities cannot silently become no-ops.
125
+ const signal = request.signal;
126
+ const beforeExecute = request.beforeExecute;
127
+ const afterExecute = request.afterExecute;
128
+ if (!signal || typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function'
129
+ || typeof beforeExecute !== 'function' || typeof afterExecute !== 'function') {
130
+ throw new Error('Tool batch signal, beforeExecute and afterExecute capabilities are required');
131
+ }
132
+ const results = [];
133
+ let stopped = null;
134
+ let recordingError;
135
+ for (const call of calls) {
136
+ const identity = freeze({ ...scope, toolCallId: call.id, name: call.name });
137
+ let started = false;
138
+ let cleanup = { status: 'not_acquired' };
139
+ let value = failure('not_started', 'Tool was not started');
140
+ const cancelled = () => {
141
+ if (!signal.aborted)
142
+ return false;
143
+ stopped ??= 'cancelled';
144
+ value = failure('cancelled_before_start', 'Cancelled before tool execution');
145
+ return true;
146
+ };
147
+ const attempt = async () => {
148
+ if (cancelled())
149
+ return;
150
+ const tool = registry.get(call.name);
151
+ if (!tool) {
152
+ value = failure('unknown_tool', 'Unknown tool');
153
+ return;
154
+ }
155
+ try {
156
+ const invalid = tool.validate(call.arguments);
157
+ if (invalid !== null) {
158
+ value = outcome({ ok: false, error: invalid }, identity, true);
159
+ if (!value.ok && value.error.code === 'tool_protocol')
160
+ stopped = 'capability_failed';
161
+ return;
162
+ }
163
+ }
164
+ catch {
165
+ value = failure('tool_validation', 'Tool validation capability failed');
166
+ stopped = 'capability_failed';
167
+ return;
168
+ }
169
+ if (cancelled())
170
+ return;
171
+ const invocation = freeze({ identity, call, definition: tool.definition });
172
+ let lease;
173
+ try {
174
+ lease = await acquire(invocation, signal);
175
+ }
176
+ catch {
177
+ value = failure('environment_failed', 'Environment acquisition failed');
178
+ cleanup = { status: 'acquire_failed', error: {
179
+ code: 'acquire_failed', message: 'Environment provider owns partial-resource cleanup; no lease was returned',
180
+ } };
181
+ stopped = 'capability_failed';
182
+ return;
183
+ }
184
+ // A malformed lease cannot prove acquired resources were released.
185
+ let release;
186
+ try {
187
+ if (!lease || typeof lease.release !== 'function')
188
+ throw new Error();
189
+ release = lease.release.bind(lease);
190
+ }
191
+ catch {
192
+ value = failure('environment_protocol', 'Environment returned an invalid resource lease');
193
+ cleanup = { status: 'failed', error: {
194
+ code: 'invalid_lease', message: 'No release capability; resource effects unknown',
195
+ } };
196
+ stopped = 'cleanup_failed';
197
+ return;
198
+ }
199
+ try {
200
+ // Use the same environment for authorization and execution, even if
201
+ // the lease implementation exposes its value through an accessor.
202
+ let environment;
203
+ try {
204
+ environment = lease.value;
205
+ }
206
+ catch {
207
+ value = failure('environment_failed', 'Environment value is unavailable');
208
+ stopped = 'capability_failed';
209
+ return;
210
+ }
211
+ if (cancelled())
212
+ return;
213
+ try {
214
+ await beforeExecute(identity);
215
+ }
216
+ catch {
217
+ value = failure('record_failed', 'Required execution-intent recording failed');
218
+ stopped = 'capability_failed';
219
+ return;
220
+ }
221
+ if (cancelled())
222
+ return;
223
+ // Permission is the last awaited boundary before tool invocation.
224
+ let decision;
225
+ try {
226
+ decision = snapshot(await check(invocation, environment, signal), 4096);
227
+ if (!object(decision) || !(decision.allowed === true
228
+ || (decision.allowed === false && typeof decision.reason === 'string')))
229
+ throw new Error();
230
+ }
231
+ catch {
232
+ value = failure('permission_failed', 'Permission capability failed or returned an invalid decision');
233
+ stopped = 'capability_failed';
234
+ return;
235
+ }
236
+ if (cancelled())
237
+ return;
238
+ if (!decision.allowed) {
239
+ value = failure('permission_denied', decision.reason);
240
+ return;
241
+ }
242
+ started = true;
243
+ try {
244
+ value = outcome(await tool.execute(call.arguments, identity, signal, environment), identity);
245
+ }
246
+ catch {
247
+ value = failure('tool_exception', 'Tool threw; actual effects are unknown', 'unknown');
248
+ }
249
+ }
250
+ finally {
251
+ try {
252
+ await release();
253
+ cleanup = { status: 'released' };
254
+ }
255
+ catch {
256
+ cleanup = { status: 'failed', error: { code: 'release_failed', message: 'Resource release failed; inspect this invocation' } };
257
+ stopped = 'cleanup_failed';
258
+ }
259
+ }
260
+ };
261
+ if (!stopped)
262
+ await attempt();
263
+ else if (stopped === 'cancelled')
264
+ cancelled();
265
+ if (!value.ok && value.error.effect === 'unknown')
266
+ stopped = 'unknown_effect';
267
+ if (signal.aborted)
268
+ stopped ??= 'cancelled';
269
+ const settlement = freeze({
270
+ identity, started, status: status(started, value), outcome: value,
271
+ cancellationRequested: signal.aborted, cleanup,
272
+ });
273
+ results.push(settlement);
274
+ if (!recordingError) {
275
+ try {
276
+ await afterExecute(settlement);
277
+ }
278
+ catch {
279
+ recordingError = {
280
+ identity, code: 'result_record_failed',
281
+ message: 'Result recording failed; write effect unknown. Reconcile using in-memory receipts',
282
+ };
283
+ stopped ??= 'capability_failed';
284
+ }
285
+ }
286
+ }
287
+ return freeze({ results, stopped, ...(recordingError ? { recordingError } : {}) });
288
+ },
289
+ });
290
+ }
@@ -0,0 +1,4 @@
1
+ import { buildExecutor } from './executor.js';
2
+ export function createToolExecutor(options) {
3
+ return buildExecutor(options);
4
+ }
@@ -57,10 +57,10 @@ export function response(value, usedIds) {
57
57
  }
58
58
  return snapshot(value);
59
59
  }
60
- export function history(value) {
60
+ export function history(value, maxBytes = limits.historyBytes) {
61
61
  const pending = new Map();
62
62
  const used = new Set();
63
- if (!Array.isArray(value) || !json(value) || size(value) > limits.historyBytes) {
63
+ if (!Array.isArray(value) || !json(value) || size(value) > maxBytes) {
64
64
  throw new AgentFault('invalid_history', 'History must be bounded JSON messages');
65
65
  }
66
66
  for (const m of value) {
@@ -821,7 +821,7 @@ export class NodeGitWorkspace {
821
821
  const state = await this.inspectWorktree(input);
822
822
  if (state === "dirty")
823
823
  return state;
824
- const container = await canonicalContainer(input.container, false);
824
+ const container = await cleanupContainer(input.container);
825
825
  const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
826
826
  const path = managedPath(container, input.directory);
827
827
  const project = await this.inspect(input.repositoryPath);
@@ -970,13 +970,13 @@ export class NodeGitWorkspace {
970
970
  return "removed";
971
971
  }
972
972
  async inspectWorktree(input) {
973
- const container = await canonicalContainer(input.container, false);
973
+ const container = await cleanupContainer(input.container);
974
974
  const path = managedPath(container, input.directory);
975
975
  const kind = await pathKind(path);
976
976
  const branch = input.expectedBranch ?? worktreeIdentity(input.taskSegment, input.roleName).branch;
977
977
  if (kind === undefined) {
978
978
  const project = await this.inspect(input.repositoryPath);
979
- await inspectMissingWorktreeRegistration(project.root, path, branch);
979
+ await inspectMissingWorktreeRegistration(project.root, path, branch, input.expectedBranch !== undefined);
980
980
  return "missing";
981
981
  }
982
982
  if (kind === "symlink")
@@ -984,7 +984,7 @@ export class NodeGitWorkspace {
984
984
  const canonicalContainerPath = await canonicalContainer(container, false);
985
985
  const project = await this.inspect(input.repositoryPath);
986
986
  await assertOwnedWorktree(project, canonicalContainerPath, path);
987
- await assertExpectedBranch(path, branch);
987
+ await assertExpectedBranch(path, branch, input.expectedBranch !== undefined);
988
988
  const porcelain = await readWorktreeStatus(path);
989
989
  return porcelain.length > 0 ? "dirty" : "clean";
990
990
  }
@@ -1000,19 +1000,19 @@ export class NodeGitWorkspace {
1000
1000
  });
1001
1001
  }
1002
1002
  async #removeManagedWorktree(input) {
1003
- const container = await canonicalContainer(input.container, false);
1003
+ const container = await cleanupContainer(input.container);
1004
1004
  const path = managedPath(container, input.identity.directory);
1005
1005
  const kind = await pathKind(path);
1006
1006
  if (kind === "symlink")
1007
1007
  throw new Error("Managed worktree path must not be a symbolic link.");
1008
1008
  const repository = await this.inspect(input.repositoryPath);
1009
1009
  if (kind === undefined) {
1010
- await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch);
1010
+ await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch, true);
1011
1011
  }
1012
1012
  if (kind === "directory") {
1013
1013
  const canonicalContainerPath = await canonicalContainer(container, false);
1014
1014
  await assertOwnedWorktree(repository, canonicalContainerPath, path);
1015
- await assertExpectedBranch(path, input.identity.branch);
1015
+ await assertExpectedBranch(path, input.identity.branch, true);
1016
1016
  if (input.discardChanges === true) {
1017
1017
  await git(["-C", repository.root, "worktree", "remove", "--force", "--", path]);
1018
1018
  }
@@ -1043,19 +1043,22 @@ export class NodeGitWorkspace {
1043
1043
  /** Missing paths do not imply missing Git metadata. Never prune another
1044
1044
  * workspace's registration as a side effect of cleaning this exact owner.
1045
1045
  */
1046
- async function removeMissingWorktreeRegistration(repositoryRoot, path, branch) {
1047
- if (!await inspectMissingWorktreeRegistration(repositoryRoot, path, branch))
1046
+ async function removeMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached = false) {
1047
+ if (!await inspectMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached))
1048
1048
  return;
1049
1049
  if (await pathKind(path) !== undefined)
1050
1050
  throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
1051
1051
  await git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
1052
1052
  }
1053
- async function inspectMissingWorktreeRegistration(repositoryRoot, path, branch) {
1053
+ async function inspectMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached = false) {
1054
1054
  const records = (await git(["-C", repositoryRoot, "worktree", "list", "--porcelain", "-z"])).split("\0\0");
1055
1055
  const fields = records.map(record => record.split("\0")).find(record => record.includes(`worktree ${path}`));
1056
1056
  if (fields === undefined)
1057
1057
  return false;
1058
- if (!fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field.startsWith("locked"))) {
1058
+ const pinnedDetached = allowDetached && fields.includes("detached")
1059
+ && await gitSucceeds(["-C", repositoryRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`])
1060
+ && fields.includes(`HEAD ${await resolveRefCommit(repositoryRoot, `refs/heads/${branch}`)}`);
1061
+ if ((!fields.includes(`branch refs/heads/${branch}`) && !pinnedDetached) || fields.some(field => field.startsWith("locked"))) {
1059
1062
  cleanupFailure("git-registration-mismatch", "Missing worktree has uncertain or locked Git ownership; metadata retained.", { branch, locked: false }, { branch: fields.find(field => field.startsWith("branch "))?.slice(7) ?? null,
1060
1063
  locked: fields.some(field => field.startsWith("locked")) });
1061
1064
  }
@@ -1115,7 +1118,12 @@ async function assertExpectedBranch(path, expected, allowRebase = false) {
1115
1118
  return;
1116
1119
  throw new Error(`Managed worktree is rebasing an unexpected branch: ${headName}.`);
1117
1120
  }
1118
- throw new Error("Managed worktree has detached HEAD without its expected rebase.");
1121
+ // A terminal Integration can legitimately retain a detached checkout.
1122
+ // Its exact owned path/common-dir were checked by the caller; require its
1123
+ // retained branch to pin the same commit before allowing clean removal.
1124
+ if (await resolveRefCommit(path, "HEAD") === await resolveRefCommit(path, `refs/heads/${expected}`))
1125
+ return;
1126
+ cleanupFailure("workspace-identity-mismatch", "Detached worktree is not pinned by its owned branch or rebase.", expected, "unproven detached HEAD");
1119
1127
  }
1120
1128
  const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
1121
1129
  if (branch !== expected) {
@@ -1237,6 +1245,27 @@ async function canonicalContainer(path, create) {
1237
1245
  // stay rejected by the managed-path lstat checks.
1238
1246
  return canonicalDirectory(lexical, "Worktree container");
1239
1247
  }
1248
+ /** A previous removal may have deleted the whole owner container before its
1249
+ * receipt committed. Resolve only surviving ancestors, without creating it.
1250
+ * Git registration inspection remains mandatory even when this path is absent.
1251
+ */
1252
+ async function cleanupContainer(path) {
1253
+ const lexical = resolve(path);
1254
+ if (await pathKind(lexical) === "symlink") {
1255
+ cleanupFailure("workspace-identity-mismatch", "Cleanup container must not be a symbolic link.", "owned directory or missing path", "symbolic link");
1256
+ }
1257
+ try {
1258
+ return await canonicalContainer(lexical, false);
1259
+ }
1260
+ catch (error) {
1261
+ if (!isErrno(error, "ENOENT"))
1262
+ throw error;
1263
+ const parent = dirname(lexical);
1264
+ if (parent === lexical)
1265
+ throw error;
1266
+ return join(await cleanupContainer(parent), relative(parent, lexical));
1267
+ }
1268
+ }
1240
1269
  async function canonicalDirectory(path, label) {
1241
1270
  const value = requireText(path, label);
1242
1271
  const canonical = await realpath(isAbsolute(value) ? value : resolve(value));