@orion-agents/orion-code 0.3.0 → 0.3.2

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 (172) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +45 -18
  3. package/README.zh-CN.md +39 -18
  4. package/dist/commands/model-command-handlers.d.ts.map +1 -1
  5. package/dist/commands/model-command-handlers.js +59 -14
  6. package/dist/commands/model-command-handlers.js.map +1 -1
  7. package/dist/commands/types.d.ts +6 -0
  8. package/dist/commands/types.d.ts.map +1 -1
  9. package/dist/commands/types.js.map +1 -1
  10. package/dist/commands/workflow-command-handlers.d.ts +1 -1
  11. package/dist/commands/workflow-command-handlers.d.ts.map +1 -1
  12. package/dist/commands/workflow-command-handlers.js +42 -3
  13. package/dist/commands/workflow-command-handlers.js.map +1 -1
  14. package/dist/commands/workflow-commands.d.ts.map +1 -1
  15. package/dist/commands/workflow-commands.js +2 -2
  16. package/dist/commands/workflow-commands.js.map +1 -1
  17. package/dist/core/tool-artifacts.d.ts +2 -0
  18. package/dist/core/tool-artifacts.d.ts.map +1 -1
  19. package/dist/core/tool-artifacts.js +27 -1
  20. package/dist/core/tool-artifacts.js.map +1 -1
  21. package/dist/runtime/agent-runtime-controller.d.ts +16 -2
  22. package/dist/runtime/agent-runtime-controller.d.ts.map +1 -1
  23. package/dist/runtime/agent-runtime-controller.js +116 -8
  24. package/dist/runtime/agent-runtime-controller.js.map +1 -1
  25. package/dist/runtime/agent-runtime-protocol.d.ts +7 -0
  26. package/dist/runtime/agent-runtime-protocol.d.ts.map +1 -1
  27. package/dist/runtime/agent-runtime-protocol.js.map +1 -1
  28. package/dist/runtime/agent-runtime-runner.d.ts +8 -0
  29. package/dist/runtime/agent-runtime-runner.d.ts.map +1 -1
  30. package/dist/runtime/durable-tool-receipt-reader.d.ts +32 -0
  31. package/dist/runtime/durable-tool-receipt-reader.d.ts.map +1 -0
  32. package/dist/runtime/durable-tool-receipt-reader.js +130 -0
  33. package/dist/runtime/durable-tool-receipt-reader.js.map +1 -0
  34. package/dist/runtime/legacy-thread-materializer.d.ts +14 -1
  35. package/dist/runtime/legacy-thread-materializer.d.ts.map +1 -1
  36. package/dist/runtime/legacy-thread-materializer.js +44 -5
  37. package/dist/runtime/legacy-thread-materializer.js.map +1 -1
  38. package/dist/runtime/orion-session-runner.d.ts +4 -0
  39. package/dist/runtime/orion-session-runner.d.ts.map +1 -1
  40. package/dist/runtime/orion-session-runner.js +15 -0
  41. package/dist/runtime/orion-session-runner.js.map +1 -1
  42. package/dist/runtime/plan-review.d.ts +38 -0
  43. package/dist/runtime/plan-review.d.ts.map +1 -0
  44. package/dist/runtime/plan-review.js +173 -0
  45. package/dist/runtime/plan-review.js.map +1 -0
  46. package/dist/runtime/product-bootstrap.d.ts.map +1 -1
  47. package/dist/runtime/product-bootstrap.js +28 -6
  48. package/dist/runtime/product-bootstrap.js.map +1 -1
  49. package/dist/runtime/product-orion-runtime.d.ts.map +1 -1
  50. package/dist/runtime/product-orion-runtime.js +14 -35
  51. package/dist/runtime/product-orion-runtime.js.map +1 -1
  52. package/dist/runtime/protocol/runtime-protocol-v1.d.ts +73 -0
  53. package/dist/runtime/protocol/runtime-protocol-v1.d.ts.map +1 -1
  54. package/dist/runtime/protocol/runtime-protocol-v1.js +37 -0
  55. package/dist/runtime/protocol/runtime-protocol-v1.js.map +1 -1
  56. package/dist/runtime/release-receipts.d.ts +8 -3
  57. package/dist/runtime/release-receipts.d.ts.map +1 -1
  58. package/dist/runtime/release-receipts.js +88 -4
  59. package/dist/runtime/release-receipts.js.map +1 -1
  60. package/dist/runtime/session-composer-control.d.ts +136 -0
  61. package/dist/runtime/session-composer-control.d.ts.map +1 -0
  62. package/dist/runtime/session-composer-control.js +532 -0
  63. package/dist/runtime/session-composer-control.js.map +1 -0
  64. package/dist/runtime/thread-event-store.d.ts +62 -9
  65. package/dist/runtime/thread-event-store.d.ts.map +1 -1
  66. package/dist/runtime/thread-event-store.js +363 -31
  67. package/dist/runtime/thread-event-store.js.map +1 -1
  68. package/dist/runtime/thread-projection.d.ts +17 -0
  69. package/dist/runtime/thread-projection.d.ts.map +1 -1
  70. package/dist/runtime/thread-projection.js +87 -0
  71. package/dist/runtime/thread-projection.js.map +1 -1
  72. package/dist/runtime/thread-session-index.d.ts +84 -0
  73. package/dist/runtime/thread-session-index.d.ts.map +1 -0
  74. package/dist/runtime/thread-session-index.js +503 -0
  75. package/dist/runtime/thread-session-index.js.map +1 -0
  76. package/dist/runtime/thread-session-view.d.ts +31 -13
  77. package/dist/runtime/thread-session-view.d.ts.map +1 -1
  78. package/dist/runtime/thread-session-view.js +116 -61
  79. package/dist/runtime/thread-session-view.js.map +1 -1
  80. package/dist/runtime/thread-ui-adapter.d.ts.map +1 -1
  81. package/dist/runtime/thread-ui-adapter.js +18 -58
  82. package/dist/runtime/thread-ui-adapter.js.map +1 -1
  83. package/dist/runtime/tool-detail-repository.d.ts.map +1 -1
  84. package/dist/runtime/tool-detail-repository.js +23 -19
  85. package/dist/runtime/tool-detail-repository.js.map +1 -1
  86. package/dist/runtime/tool-receipt-validator.d.ts +21 -0
  87. package/dist/runtime/tool-receipt-validator.d.ts.map +1 -0
  88. package/dist/runtime/tool-receipt-validator.js +157 -0
  89. package/dist/runtime/tool-receipt-validator.js.map +1 -0
  90. package/dist/runtime/ui-events.d.ts +6 -1
  91. package/dist/runtime/ui-events.d.ts.map +1 -1
  92. package/dist/runtime/ui-events.js.map +1 -1
  93. package/dist/services/redaction.d.ts +4 -0
  94. package/dist/services/redaction.d.ts.map +1 -1
  95. package/dist/services/redaction.js +63 -0
  96. package/dist/services/redaction.js.map +1 -1
  97. package/dist/services/session-index.d.ts +16 -0
  98. package/dist/services/session-index.d.ts.map +1 -1
  99. package/dist/services/session-index.js +45 -36
  100. package/dist/services/session-index.js.map +1 -1
  101. package/dist/services/session-storage.d.ts +36 -0
  102. package/dist/services/session-storage.d.ts.map +1 -1
  103. package/dist/services/session-storage.js +80 -7
  104. package/dist/services/session-storage.js.map +1 -1
  105. package/dist/services/workspace-registry.d.ts +42 -0
  106. package/dist/services/workspace-registry.d.ts.map +1 -0
  107. package/dist/services/workspace-registry.js +222 -0
  108. package/dist/services/workspace-registry.js.map +1 -0
  109. package/dist/web/errors.d.ts +6 -0
  110. package/dist/web/errors.d.ts.map +1 -0
  111. package/dist/web/errors.js +13 -0
  112. package/dist/web/errors.js.map +1 -0
  113. package/dist/web/event-hub.d.ts +4 -0
  114. package/dist/web/event-hub.d.ts.map +1 -1
  115. package/dist/web/event-hub.js +81 -11
  116. package/dist/web/event-hub.js.map +1 -1
  117. package/dist/web/file-read-service.d.ts +64 -0
  118. package/dist/web/file-read-service.d.ts.map +1 -0
  119. package/dist/web/file-read-service.js +409 -0
  120. package/dist/web/file-read-service.js.map +1 -0
  121. package/dist/web/git-read-model-service.d.ts +85 -0
  122. package/dist/web/git-read-model-service.d.ts.map +1 -0
  123. package/dist/web/git-read-model-service.js +657 -0
  124. package/dist/web/git-read-model-service.js.map +1 -0
  125. package/dist/web/protocol.d.ts +205 -1
  126. package/dist/web/protocol.d.ts.map +1 -1
  127. package/dist/web/protocol.js +229 -1
  128. package/dist/web/protocol.js.map +1 -1
  129. package/dist/web/review-service.d.ts +38 -0
  130. package/dist/web/review-service.d.ts.map +1 -0
  131. package/dist/web/review-service.js +74 -0
  132. package/dist/web/review-service.js.map +1 -0
  133. package/dist/web/server.d.ts.map +1 -1
  134. package/dist/web/server.js +312 -29
  135. package/dist/web/server.js.map +1 -1
  136. package/dist/web/terminal-manager.d.ts +156 -0
  137. package/dist/web/terminal-manager.d.ts.map +1 -0
  138. package/dist/web/terminal-manager.js +760 -0
  139. package/dist/web/terminal-manager.js.map +1 -0
  140. package/dist/web/terminal-server.d.ts +8 -0
  141. package/dist/web/terminal-server.d.ts.map +1 -0
  142. package/dist/web/terminal-server.js +275 -0
  143. package/dist/web/terminal-server.js.map +1 -0
  144. package/dist/web/workbench-controller.d.ts +71 -18
  145. package/dist/web/workbench-controller.d.ts.map +1 -1
  146. package/dist/web/workbench-controller.js +1187 -178
  147. package/dist/web/workbench-controller.js.map +1 -1
  148. package/dist/web-client/assets/DiffViewer-eaQNsnCh.js +5 -0
  149. package/dist/web-client/assets/FilesPanel-DYs1MhoM.js +2 -0
  150. package/dist/web-client/assets/GitPanel-CnU4XVr7.js +1 -0
  151. package/dist/web-client/assets/ReviewPanel-BDxG670T.js +1 -0
  152. package/dist/web-client/assets/TerminalPanel-DLuoa74B.css +1 -0
  153. package/dist/web-client/assets/TerminalPanel-Q5mSSsoK.js +23 -0
  154. package/dist/web-client/assets/index-BWV5sIvw.css +1 -0
  155. package/dist/web-client/assets/index-VVUKj6FE.js +17 -0
  156. package/dist/web-client/index.html +2 -2
  157. package/docs/architecture/agent-mode-permission-contract.md +87 -0
  158. package/docs/architecture/v0.3.0-web-api.yaml +44 -13
  159. package/docs/architecture/v0.3.1-web-api.yaml +2338 -0
  160. package/docs/architecture/v0.3.2-web-api.yaml +3029 -0
  161. package/docs/migration/v0.3.0-to-v0.3.1.md +88 -0
  162. package/docs/migration/v0.3.1-to-v0.3.2.md +94 -0
  163. package/docs/plan/v0.3.0-web-workbench-plan.md +58 -7
  164. package/docs/plan/v0.3.1-web-workbench-professional-shell-plan.md +848 -0
  165. package/docs/plan/v0.3.2-web-workbench-layout-and-composer-plan.md +869 -0
  166. package/docs/readme.md +20 -0
  167. package/docs/test/v0.3.1-web-workbench-e2e-plan.md +140 -0
  168. package/docs/test/v0.3.2-web-workbench-e2e-plan.md +100 -0
  169. package/npm-shrinkwrap.json +185 -354
  170. package/package.json +21 -4
  171. package/dist/web-client/assets/index-BAEO71Gp.css +0 -1
  172. package/dist/web-client/assets/index-DSkFy7gM.js +0 -13
@@ -2,30 +2,39 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WebWorkbenchController = exports.WebWorkbenchError = void 0;
4
4
  exports.pageItems = pageItems;
5
+ exports.pageCollectionItems = pageCollectionItems;
6
+ const crypto_1 = require("crypto");
5
7
  const fs_1 = require("fs");
6
8
  const path_1 = require("path");
7
9
  const agent_runtime_controller_1 = require("../runtime/agent-runtime-controller");
10
+ const durable_tool_receipt_reader_1 = require("../runtime/durable-tool-receipt-reader");
8
11
  const mcp_1 = require("../runtime/mcp");
9
12
  const product_bootstrap_1 = require("../runtime/product-bootstrap");
10
13
  const canonical_1 = require("../runtime/protocol/canonical");
14
+ const plan_review_1 = require("../runtime/plan-review");
15
+ const thread_session_index_1 = require("../runtime/thread-session-index");
11
16
  const thread_session_view_1 = require("../runtime/thread-session-view");
12
17
  const tool_detail_repository_1 = require("../runtime/tool-detail-repository");
13
18
  const turn_commit_1 = require("../runtime/turn-commit");
19
+ const session_composer_control_1 = require("../runtime/session-composer-control");
14
20
  const global_config_1 = require("../services/global-config");
21
+ const redaction_1 = require("../services/redaction");
15
22
  const settings_coordinator_1 = require("../services/settings-coordinator");
16
23
  const session_storage_1 = require("../services/session-storage");
24
+ const workspace_registry_1 = require("../services/workspace-registry");
25
+ const errors_1 = require("./errors");
17
26
  const event_hub_1 = require("./event-hub");
27
+ const file_read_service_1 = require("./file-read-service");
28
+ const git_read_model_service_1 = require("./git-read-model-service");
18
29
  const protocol_1 = require("./protocol");
19
- class WebWorkbenchError extends Error {
20
- constructor(status, message, code = status === 409 ? 'request_conflict' : 'web_workbench_error') {
21
- super(message);
22
- this.status = status;
23
- this.code = code;
24
- this.name = 'WebWorkbenchError';
25
- }
26
- }
27
- exports.WebWorkbenchError = WebWorkbenchError;
30
+ const review_service_1 = require("./review-service");
31
+ const terminal_manager_1 = require("./terminal-manager");
32
+ var errors_2 = require("./errors");
33
+ Object.defineProperty(exports, "WebWorkbenchError", { enumerable: true, get: function () { return errors_2.WebWorkbenchError; } });
28
34
  const SESSION_VIEW_CACHE_BYTES = 16 * 1024 * 1024;
35
+ const MAX_MUTATION_RESULTS = 4096;
36
+ const MAX_CONTEXT_REFERENCE_BYTES = 64 * 1024;
37
+ const MAX_CONTEXT_MANIFEST_BYTES = 256 * 1024;
29
38
  /** Owns the sole runtime/controller pair exposed through the local Web host. */
30
39
  class WebWorkbenchController {
31
40
  constructor(options) {
@@ -33,10 +42,29 @@ class WebWorkbenchController {
33
42
  this.sessionViews = new Map();
34
43
  this.sessionViewBytes = 0;
35
44
  this.toolDetails = new tool_detail_repository_1.FileToolDetailRepository();
45
+ this.contextRevisionValue = (0, crypto_1.randomUUID)();
46
+ this.composerRevisionValue = (0, crypto_1.randomUUID)();
47
+ this.suppressComposerEdges = 0;
48
+ this.suppressContextEdges = 0;
36
49
  this.latestStatus = 'Ready';
50
+ this.sessionTransition = false;
37
51
  this.closed = false;
52
+ this.workspaceWatchers = [];
38
53
  this.workspaceValue = canonicalDirectory(options.cwd);
39
54
  this.eventHub = options.eventHub ?? new event_hub_1.WebEventHub();
55
+ this.workspaceRegistry = options.workspaceRegistry ?? new workspace_registry_1.WorkspaceRegistryV1();
56
+ this.terminalManager = new terminal_manager_1.TerminalManagerV1({
57
+ resolveWorkspace: workspaceId => this.workspaceRegistry.find(workspaceId)?.canonicalPath,
58
+ getActiveContext: () => ({
59
+ workspaceId: this.activeWorkspaceEntry().id,
60
+ contextRevision: this.contextRevisionValue,
61
+ }),
62
+ onWorkspaceMutationHint: workspaceId => {
63
+ if (workspaceId === this.activeWorkspaceEntry().id) {
64
+ this.scheduleWorkspaceResourceInvalidation('terminal-command');
65
+ }
66
+ },
67
+ });
40
68
  this.createRuntime =
41
69
  options.createRuntime ??
42
70
  (cwd => (0, product_bootstrap_1.createProductUiRuntime)({
@@ -44,6 +72,8 @@ class WebWorkbenchController {
44
72
  shutdownReason: 'Orion Web Workbench shutdown',
45
73
  onActiveSessionRuntime: (runtime, sessionId, activation) => this.observeThreadRuntime(runtime, sessionId, activation),
46
74
  onSettingsInvalidated: event => {
75
+ if (this.suppressContextEdges > 0)
76
+ return;
47
77
  this.eventHub.emit({
48
78
  type: 'settings_invalidated',
49
79
  revision: event.revision,
@@ -55,6 +85,7 @@ class WebWorkbenchController {
55
85
  }
56
86
  static async create(options) {
57
87
  const workbench = new WebWorkbenchController(options);
88
+ workbench.initializeWorkspaceRegistry();
58
89
  await workbench.installRuntime(workbench.workspaceValue);
59
90
  return workbench;
60
91
  }
@@ -67,11 +98,16 @@ class WebWorkbenchController {
67
98
  get controller() {
68
99
  return this.controllerValue;
69
100
  }
101
+ get contextRevision() {
102
+ return this.contextRevisionValue;
103
+ }
70
104
  bootstrap(nonce) {
71
105
  return Object.freeze({
72
106
  apiVersion: protocol_1.WEB_API_VERSION,
73
107
  productVersion: this.runtimeValue.version,
74
108
  nonce,
109
+ contextRevision: this.contextRevisionValue,
110
+ workspaceId: this.activeWorkspaceEntry().id,
75
111
  workspace: this.workspaceValue,
76
112
  configured: this.runtimeValue.isConfigured,
77
113
  activeSessionId: this.runtimeValue.getSession()?.id ?? null,
@@ -82,6 +118,10 @@ class WebWorkbenchController {
82
118
  skills: true,
83
119
  mcp: true,
84
120
  diagnostics: true,
121
+ review: true,
122
+ files: true,
123
+ git: true,
124
+ terminal: this.terminalManager.available,
85
125
  },
86
126
  });
87
127
  }
@@ -89,7 +129,7 @@ class WebWorkbenchController {
89
129
  this.assertSettingsAvailable();
90
130
  const describe = this.runtimeValue.describeSettings;
91
131
  if (!describe) {
92
- throw new WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
132
+ throw new errors_1.WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
93
133
  }
94
134
  try {
95
135
  return describe();
@@ -98,86 +138,218 @@ class WebWorkbenchController {
98
138
  throw mapSettingsError(error);
99
139
  }
100
140
  }
101
- listWorkspaces() {
102
- const paths = new Set([this.workspaceValue]);
103
- const counts = new Map();
104
- for (const session of (0, session_storage_1.listSessions)()) {
141
+ listWorkspaces(context) {
142
+ if (context)
143
+ this.assertContextGuard(context);
144
+ // Session metadata is canonicalized before it enters the catalog. The
145
+ // summary read model deliberately avoids sorting Sessions or re-running
146
+ // realpath/stat for every historical entry. Availability is checked once
147
+ // per registered Workspace below.
148
+ const counts = (0, session_storage_1.countSessionsByProject)();
149
+ const result = Object.freeze(this.workspaceRegistry.list().map(entry => {
150
+ let available = false;
105
151
  try {
106
- const path = canonicalDirectory(session.projectPath);
107
- paths.add(path);
108
- counts.set(path, (counts.get(path) ?? 0) + 1);
152
+ available = canonicalDirectory(entry.canonicalPath) === entry.canonicalPath;
109
153
  }
110
154
  catch {
111
- // Stale projects remain in session history but are not selectable workspaces.
155
+ available = false;
112
156
  }
157
+ return Object.freeze({
158
+ id: entry.id,
159
+ path: entry.canonicalPath,
160
+ label: entry.label || (0, path_1.basename)(entry.canonicalPath) || entry.canonicalPath,
161
+ active: entry.canonicalPath === this.workspaceValue,
162
+ available,
163
+ sessionCount: counts.get(entry.canonicalPath) ?? 0,
164
+ lastActivatedAt: entry.lastActivatedAt,
165
+ ...(entry.pinnedOrder !== undefined ? { pinnedOrder: entry.pinnedOrder } : {}),
166
+ });
167
+ }));
168
+ if (context)
169
+ this.assertContextGuard(context);
170
+ return result;
171
+ }
172
+ listWorkspaceSessions(workspaceId, context) {
173
+ if (context)
174
+ this.assertContextGuard(context);
175
+ const entry = this.workspaceRegistry.find(workspaceId);
176
+ if (!entry) {
177
+ throw new errors_1.WebWorkbenchError(404, 'Workspace was not found.', 'workspace_not_found');
113
178
  }
114
- return Object.freeze([...paths].sort().map(path => Object.freeze({
115
- path,
116
- label: (0, path_1.basename)(path) || path,
117
- active: path === this.workspaceValue,
118
- sessionCount: counts.get(path) ?? 0,
119
- })));
179
+ try {
180
+ canonicalDirectory(entry.canonicalPath);
181
+ }
182
+ catch {
183
+ throw new errors_1.WebWorkbenchError(409, 'Workspace is unavailable.', 'workspace_unavailable');
184
+ }
185
+ const result = Object.freeze((0, session_storage_1.listProjectSessions)(entry.canonicalPath).map(protocol_1.projectSessionSummary));
186
+ if (context)
187
+ this.assertContextGuard(context);
188
+ return result;
120
189
  }
121
- listSessions() {
122
- return Object.freeze((0, session_storage_1.listProjectSessions)(this.workspaceValue).map(protocol_1.projectSessionSummary));
190
+ async workspaceProjectSummary(workspaceId, context) {
191
+ if (context)
192
+ this.assertContextGuard(context);
193
+ const entry = this.workspaceRegistry.find(workspaceId);
194
+ if (!entry) {
195
+ throw new errors_1.WebWorkbenchError(404, 'Workspace was not found.', 'workspace_not_found');
196
+ }
197
+ let workspace;
198
+ try {
199
+ workspace = canonicalDirectory(entry.canonicalPath);
200
+ }
201
+ catch {
202
+ throw new errors_1.WebWorkbenchError(409, 'Workspace is unavailable.', 'workspace_unavailable');
203
+ }
204
+ const status = await new git_read_model_service_1.GitReadModelServiceV1(workspace).status({ pageSize: 2000 });
205
+ const result = Object.freeze({
206
+ workspaceId,
207
+ repositoryRevision: status.repositoryRevision,
208
+ isRepository: status.isRepository,
209
+ branch: status.branch,
210
+ detached: status.detached,
211
+ head: status.head,
212
+ dirtyCount: status.totalFiles,
213
+ conflictCount: status.conflicted.length,
214
+ });
215
+ if (context)
216
+ this.assertContextGuard(context);
217
+ return result;
218
+ }
219
+ setWorkspacePinned(workspaceId, pinned, context) {
220
+ this.assertContextGuard(context);
221
+ try {
222
+ this.workspaceRegistry.setPinned(workspaceId, pinned);
223
+ }
224
+ catch (error) {
225
+ throw mapWorkspaceRegistryError(error);
226
+ }
227
+ const updated = this.listWorkspaces().find(workspace => workspace.id === workspaceId);
228
+ if (!updated) {
229
+ throw new errors_1.WebWorkbenchError(404, 'Workspace was not found.', 'workspace_not_found');
230
+ }
231
+ return updated;
232
+ }
233
+ removeWorkspace(workspaceId, context) {
234
+ this.assertContextGuard(context);
235
+ const entry = this.workspaceRegistry.find(workspaceId);
236
+ if (!entry) {
237
+ throw new errors_1.WebWorkbenchError(404, 'Workspace was not found.', 'workspace_not_found');
238
+ }
239
+ if (entry.canonicalPath === this.workspaceValue) {
240
+ throw new errors_1.WebWorkbenchError(409, 'The active Workspace cannot be removed.', 'workspace_active');
241
+ }
242
+ this.terminalManager.closeWorkspace(workspaceId);
243
+ if (!this.workspaceRegistry.remove(workspaceId)) {
244
+ throw new errors_1.WebWorkbenchError(404, 'Workspace was not found.', 'workspace_not_found');
245
+ }
246
+ }
247
+ listSessions(context) {
248
+ if (context)
249
+ this.assertContextGuard(context);
250
+ const result = Object.freeze((0, session_storage_1.listProjectSessions)(this.workspaceValue).map(protocol_1.projectSessionSummary));
251
+ if (context)
252
+ this.assertContextGuard(context);
253
+ return result;
123
254
  }
124
- sessionSnapshot(sessionId, cursor, pageSize = 50, tail = false) {
255
+ sessionSnapshot(sessionId, cursor, pageSize = 50, tail = false, context) {
256
+ if (context)
257
+ this.assertContextGuard(context);
125
258
  const session = requireSession(sessionId);
126
259
  if (canonicalDirectory(session.projectPath) !== this.workspaceValue) {
127
- throw new WebWorkbenchError(409, 'Session belongs to another workspace.');
260
+ throw new errors_1.WebWorkbenchError(409, 'Session belongs to another workspace.');
128
261
  }
129
262
  const active = this.runtimeValue.getSession()?.id === session.id;
130
263
  const activeProjection = this.activeOrionRuntime?.sessionId === session.id
131
264
  ? this.activeOrionRuntime.runtime.thread.getProjection()
132
265
  : undefined;
266
+ let indexedPage;
267
+ if (tail) {
268
+ try {
269
+ indexedPage = (0, thread_session_view_1.loadThreadSessionSnapshotPageV1)(this.workspaceValue, session.id, cursor, pageSize);
270
+ }
271
+ catch (error) {
272
+ if (error instanceof thread_session_index_1.ThreadSessionIndexError) {
273
+ throw new errors_1.WebWorkbenchError(error.code === 'ORION_THREAD_SESSION_CURSOR_STALE' ? 409 : 400, error.message, error.code === 'ORION_THREAD_SESSION_CURSOR_STALE'
274
+ ? 'transcript_cursor_stale'
275
+ : 'page_cursor_invalid');
276
+ }
277
+ throw error;
278
+ }
279
+ }
280
+ if (indexedPage &&
281
+ activeProjection &&
282
+ (indexedPage.cursor !== activeProjection.cursor ||
283
+ indexedPage.projectionDigest !== activeProjection.digest)) {
284
+ if (cursor) {
285
+ throw new errors_1.WebWorkbenchError(409, 'Active Thread changed after this transcript cursor was issued.', 'transcript_cursor_stale');
286
+ }
287
+ // Another process may have advanced the durable Thread before the active
288
+ // Runtime has adopted that head. Never combine the newer page with the
289
+ // older in-memory cursor/digest in one snapshot.
290
+ indexedPage = undefined;
291
+ }
133
292
  let view;
134
- try {
135
- view = this.loadSessionView(session, activeProjection);
293
+ if (!indexedPage && !activeProjection) {
294
+ try {
295
+ view = this.loadSessionView(session, activeProjection);
296
+ }
297
+ catch (error) {
298
+ if (!activeProjection)
299
+ throw error;
300
+ view = undefined;
301
+ }
136
302
  }
137
- catch (error) {
138
- if (!activeProjection)
139
- throw error;
140
- view = undefined;
141
- }
142
- const transcriptSource = view
143
- ? view.transcriptMessages
144
- : activeProjection
145
- ? Object.values(activeProjection.items)
146
- .filter(item => item.kind === 'message' &&
147
- item.status !== 'started' &&
148
- ['system', 'user', 'assistant', 'tool'].includes(item.role ?? ''))
149
- .sort((left, right) => left.startedSeq - right.startedSeq)
150
- .map(item => ({
151
- role: item.role,
152
- content: item.content ?? item.summary ?? item.error ?? '',
153
- timestamp: item.terminalSeq ?? item.startedSeq,
154
- }))
155
- : (0, session_storage_1.readSessionMessages)(session.id).map(message => ({
156
- role: message.role,
157
- content: message.content,
158
- timestamp: message.timestamp,
159
- ...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
160
- ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}),
161
- ...(message.modelVisibleContent
162
- ? { modelVisibleContent: message.modelVisibleContent }
163
- : {}),
164
- ...(message.appliedSkills ? { appliedSkills: message.appliedSkills } : {}),
165
- }));
166
- const tailPage = tail ? pageTranscriptTail(transcriptSource, cursor, pageSize) : undefined;
167
- const transcriptPage = tailPage ?? pageItems(transcriptSource, cursor, pageSize);
168
- const transcriptOffset = tailPage?.offset ?? (cursor ? decodePageCursor(cursor) : 0);
303
+ const transcriptSource = indexedPage
304
+ ? indexedPage.transcript.items
305
+ : view
306
+ ? view.transcriptMessages
307
+ : activeProjection
308
+ ? Object.values(activeProjection.items)
309
+ .filter(item => item.kind === 'message' &&
310
+ item.status !== 'started' &&
311
+ ['system', 'user', 'assistant', 'tool'].includes(item.role ?? ''))
312
+ .sort((left, right) => left.startedSeq - right.startedSeq)
313
+ .map(item => ({
314
+ role: item.role,
315
+ content: item.content ?? item.summary ?? item.error ?? '',
316
+ timestamp: item.terminalSeq ?? item.startedSeq,
317
+ }))
318
+ : (0, session_storage_1.readSessionMessages)(session.id).map(message => ({
319
+ role: message.role,
320
+ content: message.content,
321
+ timestamp: message.timestamp,
322
+ ...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
323
+ ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}),
324
+ ...(message.modelVisibleContent
325
+ ? { modelVisibleContent: message.modelVisibleContent }
326
+ : {}),
327
+ ...(message.appliedSkills ? { appliedSkills: message.appliedSkills } : {}),
328
+ }));
329
+ const tailPage = tail && !indexedPage ? pageTranscriptTail(transcriptSource, cursor, pageSize) : undefined;
330
+ const transcriptPage = indexedPage
331
+ ? {
332
+ items: indexedPage.transcript.items,
333
+ nextCursor: indexedPage.transcript.nextCursor,
334
+ }
335
+ : (tailPage ?? pageItems(transcriptSource, cursor, pageSize));
336
+ const transcriptOffset = indexedPage
337
+ ? indexedPage.transcript.offset
338
+ : (tailPage?.offset ?? (cursor ? decodePageCursor(cursor) : 0));
169
339
  const transcript = Object.freeze({
170
340
  items: Object.freeze(transcriptPage.items.map((message, index) => ({
171
341
  ...message,
172
342
  id: `${session.id}:message:${transcriptOffset + index + 1}`,
173
343
  }))),
174
- nextCursor: transcriptPage.nextCursor,
344
+ nextCursor: indexedPage?.transcript.nextCursor ?? transcriptPage.nextCursor,
175
345
  });
176
- const commitProjections = view
177
- ? uniqueCommitProjections([view.latestPlanTurnCommit, view.latestTurnCommit])
178
- : activeProjection
179
- ? latestAuthorityCommits(activeProjection)
180
- : [];
346
+ const commitProjections = indexedPage
347
+ ? uniqueCommitProjections([indexedPage.latestPlanTurnCommit, indexedPage.latestTurnCommit])
348
+ : view
349
+ ? uniqueCommitProjections([view.latestPlanTurnCommit, view.latestTurnCommit])
350
+ : activeProjection
351
+ ? latestAuthorityCommits(activeProjection)
352
+ : [];
181
353
  const commits = commitProjections.map(commit => (0, turn_commit_1.parseTurnCommitV1)(commit.receipt));
182
354
  const latestCommit = commits.at(-1);
183
355
  const planCommit = [...commits].reverse().find(commit => commit.planReceipt);
@@ -194,7 +366,7 @@ class WebWorkbenchController {
194
366
  allowedScopes: ['once', 'project', 'global'],
195
367
  }))
196
368
  : [];
197
- const projectionStatus = activeProjection?.status ?? (view ? 'idle' : 'legacy');
369
+ const projectionStatus = activeProjection?.status ?? (indexedPage || view ? 'idle' : 'legacy');
198
370
  const goal = latestCommit?.goalState && latestCommit.goalStateDigest
199
371
  ? {
200
372
  authority: 'turn_commit',
@@ -202,14 +374,16 @@ class WebWorkbenchController {
202
374
  state: JSON.parse(latestCommit.goalState),
203
375
  }
204
376
  : null;
205
- return Object.freeze({
377
+ const snapshot = Object.freeze({
206
378
  apiVersion: protocol_1.WEB_API_VERSION,
207
379
  session: (0, protocol_1.projectSessionSummary)(session),
208
- threadId: activeProjection?.threadId ?? view?.threadId ?? null,
209
- threadCursor: activeProjection?.cursor ?? view?.cursor ?? 0,
380
+ threadId: activeProjection?.threadId ?? indexedPage?.threadId ?? view?.threadId ?? null,
381
+ threadCursor: activeProjection?.cursor ?? indexedPage?.cursor ?? view?.cursor ?? 0,
210
382
  eventCursor: this.eventHub.snapshot().latest,
211
- ...(activeProjection?.digest || view?.projectionDigest
212
- ? { projectionDigest: activeProjection?.digest ?? view?.projectionDigest }
383
+ ...(activeProjection?.digest || indexedPage?.projectionDigest || view?.projectionDigest
384
+ ? {
385
+ projectionDigest: activeProjection?.digest ?? indexedPage?.projectionDigest ?? view?.projectionDigest,
386
+ }
213
387
  : {}),
214
388
  threadStatus: projectionStatus,
215
389
  ...(activeProjection?.activeTurnId ? { activeTurnId: activeProjection.activeTurnId } : {}),
@@ -228,12 +402,206 @@ class WebWorkbenchController {
228
402
  pendingApprovals: Object.freeze(pendingPermissions),
229
403
  goal,
230
404
  plan,
231
- recoveryDiagnostics: view?.diagnostics ?? [],
405
+ composer: this.projectComposerState(session, active),
406
+ recoveryDiagnostics: indexedPage ? [] : (view?.diagnostics ?? []),
407
+ });
408
+ if (context)
409
+ this.assertContextGuard(context);
410
+ return snapshot;
411
+ }
412
+ composerState(sessionId, context) {
413
+ if (context)
414
+ this.assertContextGuard(context);
415
+ this.assertCommandSession(sessionId);
416
+ const session = requireSession(sessionId);
417
+ const state = this.projectComposerState(session, true);
418
+ if (context)
419
+ this.assertContextGuard(context);
420
+ return state;
421
+ }
422
+ projectComposerState(session, active) {
423
+ const state = this.projectComposerStateValue(session, active);
424
+ if (!active)
425
+ return state;
426
+ // Durable Thread and Store authority can advance before the debounced SSE
427
+ // observer runs. Keep every projected control state and its CAS token in
428
+ // the same synchronous boundary so the UI never receives new controls
429
+ // paired with an older revision.
430
+ const authorityDigest = composerAuthorityDigest(state);
431
+ if (this.composerAuthorityDigest === undefined) {
432
+ this.composerAuthorityDigest = authorityDigest;
433
+ return state;
434
+ }
435
+ if (authorityDigest === this.composerAuthorityDigest)
436
+ return state;
437
+ this.bumpComposerRevision();
438
+ this.composerAuthorityDigest = authorityDigest;
439
+ return Object.freeze({ ...state, controlRevision: this.composerRevisionValue });
440
+ }
441
+ projectComposerStateValue(session, active) {
442
+ const controls = this.runtimeValue.sessionComposerControls;
443
+ if (!controls) {
444
+ throw new errors_1.WebWorkbenchError(503, 'Session Composer controls are unavailable.', 'composer_controls_unavailable');
445
+ }
446
+ const runtime = controls.describeSession(session);
447
+ const queue = active
448
+ ? this.controllerValue.getFollowupQueueSnapshot()
449
+ : { items: [], limit: 16 };
450
+ const projection = active && this.activeOrionRuntime?.sessionId === session.id
451
+ ? this.activeOrionRuntime.runtime.thread.getProjection()
452
+ : undefined;
453
+ const review = projection?.planReview;
454
+ const workspace = this.activeWorkspaceEntry();
455
+ return Object.freeze({
456
+ apiVersion: protocol_1.WEB_API_VERSION,
457
+ workspaceId: workspace.id,
458
+ sessionId: session.id,
459
+ contextRevision: this.contextRevisionValue,
460
+ controlRevision: this.composerRevisionValue,
461
+ processing: active &&
462
+ (this.runtimeValue.store.getSnapshot().isProcessing || Boolean(projection?.activeTurnId)),
463
+ mode: active
464
+ ? Object.freeze({ ...this.controllerValue.getAgentModeSnapshot() })
465
+ : Object.freeze({ baseMode: 'interactive', pendingBaseMode: null }),
466
+ model: runtime.model,
467
+ permission: runtime.permission,
468
+ contextUsage: runtime.contextUsage,
469
+ compactAvailable: active && this.controllerValue.canCompactContext(),
470
+ pending: runtime.pending,
471
+ lastError: runtime.lastError,
472
+ planReview: review
473
+ ? Object.freeze({
474
+ planDigest: review.planDigest,
475
+ revision: review.revision,
476
+ status: review.status,
477
+ createdAt: review.createdAt,
478
+ createdModel: review.createdModel,
479
+ returnMode: review.returnMode,
480
+ ...(review.resolvedAt === undefined ? {} : { resolvedAt: review.resolvedAt }),
481
+ })
482
+ : null,
483
+ queue: Object.freeze({
484
+ items: Object.freeze(queue.items.map(item => Object.freeze({ ...item }))),
485
+ limit: queue.limit,
486
+ }),
232
487
  });
233
488
  }
234
- async skills() {
489
+ modelCatalog(sessionId, context) {
490
+ if (context)
491
+ this.assertContextGuard(context);
492
+ this.assertCommandSession(sessionId);
493
+ const controls = this.runtimeValue.sessionComposerControls;
494
+ if (!controls) {
495
+ throw new errors_1.WebWorkbenchError(503, 'Session model catalog is unavailable.', 'composer_controls_unavailable');
496
+ }
497
+ const catalog = controls.catalog();
498
+ if (context)
499
+ this.assertContextGuard(context);
500
+ return catalog;
501
+ }
502
+ async applyComposerAction(input) {
503
+ this.assertContextGuard(input);
504
+ this.assertCommandSession(input.expectedSessionId);
505
+ const admittedState = this.composerState(input.expectedSessionId);
506
+ if (input.expectedControlRevision !== admittedState.controlRevision) {
507
+ throw new errors_1.WebWorkbenchError(409, 'Composer controls changed before the action was admitted.', 'composer_control_conflict');
508
+ }
509
+ const controls = this.runtimeValue.sessionComposerControls;
510
+ if (!controls) {
511
+ throw new errors_1.WebWorkbenchError(503, 'Session Composer controls are unavailable.', 'composer_controls_unavailable');
512
+ }
513
+ let outcome = 'applied';
514
+ let detail;
515
+ let modelReceipt;
516
+ let permissionReceipt;
517
+ let planReviewReceipt;
518
+ this.suppressComposerEdges += 1;
519
+ try {
520
+ switch (input.type) {
521
+ case 'set_agent_mode': {
522
+ const result = this.controllerValue.setAgentMode(input.mode);
523
+ outcome =
524
+ result.type === 'agent_mode_changed' && result.appliesFrom === 'next-logical-request'
525
+ ? 'deferred'
526
+ : 'applied';
527
+ detail = JSON.stringify(result);
528
+ break;
529
+ }
530
+ case 'set_permission_override':
531
+ permissionReceipt = await controls.setPermissionOverride(input.value);
532
+ outcome = permissionReceipt.appliesFrom === 'immediate' ? 'applied' : 'deferred';
533
+ break;
534
+ case 'clear_permission_override':
535
+ permissionReceipt = await controls.setPermissionOverride(null);
536
+ outcome = permissionReceipt.appliesFrom === 'immediate' ? 'applied' : 'deferred';
537
+ break;
538
+ case 'select_model':
539
+ modelReceipt = await controls.selectModel({
540
+ modelId: input.modelId,
541
+ ...(input.effort ? { effort: input.effort } : {}),
542
+ });
543
+ outcome = modelReceipt.appliesFrom === 'next-logical-request' ? 'deferred' : 'applied';
544
+ break;
545
+ case 'compact_context': {
546
+ const result = await this.controllerValue.compactContext();
547
+ if (result.status === 'rejected') {
548
+ throw new errors_1.WebWorkbenchError(409, 'Context compact was rejected.', 'runtime_busy');
549
+ }
550
+ detail = JSON.stringify(result);
551
+ break;
552
+ }
553
+ case 'edit_queue_item':
554
+ detail = JSON.stringify(this.controllerValue.editFollowupQueueItem(input.itemId, input.expectedItemRevision, input.text));
555
+ break;
556
+ case 'move_queue_item':
557
+ detail = JSON.stringify(this.controllerValue.moveFollowupQueueItem(input.itemId, input.expectedItemRevision, input.targetIndex));
558
+ break;
559
+ case 'remove_queue_item':
560
+ detail = JSON.stringify(this.controllerValue.removeFollowupQueueItem(input.itemId, input.expectedItemRevision));
561
+ break;
562
+ case 'review_plan': {
563
+ planReviewReceipt = await this.controllerValue.reviewPlan({
564
+ planDigest: input.planDigest,
565
+ action: input.action,
566
+ ...(input.feedback ? { feedback: input.feedback } : {}),
567
+ });
568
+ if (input.action === 'approve')
569
+ this.controllerValue.setAgentMode('interactive');
570
+ else if (input.action === 'continue')
571
+ this.controllerValue.setAgentMode('plan');
572
+ detail = JSON.stringify(planReviewReceipt.admission);
573
+ break;
574
+ }
575
+ }
576
+ }
577
+ catch (error) {
578
+ throw mapComposerControlError(error);
579
+ }
580
+ finally {
581
+ this.suppressComposerEdges -= 1;
582
+ }
583
+ this.bumpComposerRevision();
584
+ const state = this.projectComposerStateValue(requireSession(input.expectedSessionId), true);
585
+ this.composerAuthorityDigest = composerAuthorityDigest(state);
586
+ this.eventHub.emit({ type: 'composer_state_changed', state }, true, {
587
+ sessionId: input.expectedSessionId,
588
+ });
589
+ return Object.freeze({
590
+ requestId: input.requestId,
591
+ outcome,
592
+ controlRevision: this.composerRevisionValue,
593
+ state,
594
+ ...(modelReceipt ? { modelReceipt } : {}),
595
+ ...(permissionReceipt ? { permissionReceipt } : {}),
596
+ ...(planReviewReceipt ? { planReviewReceipt } : {}),
597
+ ...(detail ? { detail } : {}),
598
+ });
599
+ }
600
+ async skills(context) {
601
+ if (context)
602
+ this.assertContextGuard(context);
235
603
  const descriptors = (await this.runtimeValue.inspectSkills?.()) ?? [];
236
- return Object.freeze(descriptors.map(descriptor => Object.freeze({
604
+ const result = Object.freeze(descriptors.map(descriptor => Object.freeze({
237
605
  id: descriptor.id,
238
606
  name: descriptor.name,
239
607
  description: descriptor.description,
@@ -244,8 +612,13 @@ class WebWorkbenchController {
244
612
  requestedCapabilities: Object.freeze([...descriptor.requestedCapabilities]),
245
613
  digest: descriptor.digest,
246
614
  })));
615
+ if (context)
616
+ this.assertContextGuard(context);
617
+ return result;
247
618
  }
248
- mcp() {
619
+ mcp(context) {
620
+ if (context)
621
+ this.assertContextGuard(context);
249
622
  const descriptors = this.runtimeValue.inspectMcp?.() ?? [];
250
623
  let runtimeServers = new Map();
251
624
  try {
@@ -257,7 +630,7 @@ class WebWorkbenchController {
257
630
  catch {
258
631
  // A concurrent session transition may close the previous diagnostics graph.
259
632
  }
260
- return Object.freeze(descriptors.map(descriptor => {
633
+ const result = Object.freeze(descriptors.map(descriptor => {
261
634
  const runtime = runtimeServers.get(descriptor.id);
262
635
  return Object.freeze({
263
636
  id: descriptor.id,
@@ -270,103 +643,228 @@ class WebWorkbenchController {
270
643
  generation: runtime?.generation ?? 0,
271
644
  toolCount: runtime?.toolCount ?? 0,
272
645
  activeCallCount: runtime?.activeCallCount ?? 0,
273
- ...(runtime?.failure ? { failure: runtime.failure } : {}),
646
+ ...(runtime?.failure
647
+ ? { failure: 'Runtime transport failure. See local Host diagnostics for details.' }
648
+ : {}),
274
649
  });
275
650
  }));
651
+ if (context)
652
+ this.assertContextGuard(context);
653
+ return result;
276
654
  }
277
- async listToolDetails() {
278
- return Object.freeze(await this.toolDetails.list(this.workspaceValue));
655
+ async listToolDetails(context) {
656
+ if (context)
657
+ this.assertContextGuard(context);
658
+ const result = Object.freeze(await this.toolDetails.list(this.workspaceValue));
659
+ if (context)
660
+ this.assertContextGuard(context);
661
+ return result;
279
662
  }
280
- async readToolDetail(callId, offsetBytes, limitBytes) {
663
+ async readToolDetail(callId, offsetBytes, limitBytes, context) {
664
+ if (context)
665
+ this.assertContextGuard(context);
281
666
  const entry = (await this.toolDetails.list(this.workspaceValue)).find(detail => detail.callId === callId || detail.artifactId === callId);
282
667
  if (!entry?.artifactId)
283
- throw new WebWorkbenchError(404, 'Tool detail was not found.');
284
- return this.toolDetails.read({
668
+ throw new errors_1.WebWorkbenchError(404, 'Tool detail was not found.');
669
+ const result = await this.toolDetails.read({
285
670
  callId: entry.callId,
286
671
  sequence: entry.sequence,
287
672
  artifactId: entry.artifactId,
288
673
  outputBytes: entry.outputBytes,
289
674
  }, { offsetBytes, limitBytes }, this.workspaceValue);
675
+ if (context)
676
+ this.assertContextGuard(context);
677
+ return result;
678
+ }
679
+ listFiles(context, input) {
680
+ this.assertContextGuard(context);
681
+ const result = this.fileService.list(input);
682
+ this.assertContextGuard(context);
683
+ return result;
290
684
  }
291
- async createSession(name) {
685
+ readFileContent(context, input) {
686
+ this.assertContextGuard(context);
687
+ const result = this.fileService.readContent(input);
688
+ this.assertContextGuard(context);
689
+ return result;
690
+ }
691
+ async gitStatus(context, input) {
692
+ this.assertContextGuard(context);
693
+ const result = await this.gitService.status(input);
694
+ this.assertContextGuard(context);
695
+ return result;
696
+ }
697
+ async gitLog(context, input) {
698
+ this.assertContextGuard(context);
699
+ const result = await this.gitService.log(input);
700
+ this.assertContextGuard(context);
701
+ return result;
702
+ }
703
+ async gitDiff(context, input) {
704
+ this.assertContextGuard(context);
705
+ const result = await this.gitService.diff(input);
706
+ this.assertContextGuard(context);
707
+ return result;
708
+ }
709
+ async review(context) {
710
+ this.assertContextGuard(context);
711
+ try {
712
+ const result = await this.reviewService.snapshot();
713
+ this.assertContextGuard(context);
714
+ return result;
715
+ }
716
+ catch (error) {
717
+ if (error instanceof durable_tool_receipt_reader_1.DurableToolReceiptReaderError) {
718
+ throw new errors_1.WebWorkbenchError(500, 'Durable Review receipt facts failed integrity validation.', 'review_receipt_invalid');
719
+ }
720
+ throw error;
721
+ }
722
+ }
723
+ listTerminals(context) {
724
+ this.assertContextGuard(context);
725
+ const result = this.terminalManager.list(this.activeWorkspaceEntry().id);
726
+ this.assertContextGuard(context);
727
+ return result;
728
+ }
729
+ async createSession(name, context) {
292
730
  this.assertReadyForTransition('create a session');
293
- // A new Session inherits the durable default; an override on the currently
294
- // active Session must never leak into the next Session.
295
- const session = (0, session_storage_1.createSession)(this.workspaceValue, this.settings().sections.defaults.model.effectiveValue);
296
- (0, global_config_1.incrementSessionCount)();
297
- const named = name?.trim() ? ((0, session_storage_1.renameSession)(session.id, name.trim()) ?? session) : session;
298
- this.runtimeValue.setSession(named);
299
- await this.runtimeValue.rebindSessionRuntime?.();
300
- this.eventHub.emitRuntime({ type: 'transcript_clear' });
301
- this.emitState();
302
- return (0, protocol_1.projectSessionSummary)(named);
303
- }
304
- async activateSession(sessionId) {
731
+ if (context)
732
+ this.assertContextGuard(context);
733
+ this.sessionTransition = true;
734
+ try {
735
+ // A new Session inherits the durable default; an override on the currently
736
+ // active Session must never leak into the next Session.
737
+ const session = (0, session_storage_1.createSession)(this.workspaceValue, this.settings().sections.defaults.model.effectiveValue);
738
+ (0, global_config_1.incrementSessionCount)();
739
+ const named = name?.trim() ? ((0, session_storage_1.renameSession)(session.id, name.trim()) ?? session) : session;
740
+ this.runtimeValue.setSession(named);
741
+ await this.runtimeValue.rebindSessionRuntime?.();
742
+ this.eventHub.emitRuntime({ type: 'transcript_clear' });
743
+ this.emitState();
744
+ return (0, protocol_1.projectSessionSummary)(named);
745
+ }
746
+ finally {
747
+ this.sessionTransition = false;
748
+ }
749
+ }
750
+ async activateSession(sessionId, context) {
305
751
  this.assertReadyForTransition('switch sessions');
306
- const session = requireSession(sessionId);
307
- if (canonicalDirectory(session.projectPath) !== this.workspaceValue) {
308
- throw new WebWorkbenchError(409, 'Session belongs to another workspace.');
752
+ if (context)
753
+ this.assertContextGuard(context);
754
+ this.sessionTransition = true;
755
+ this.suppressContextEdges += 1;
756
+ let activated = false;
757
+ try {
758
+ const session = requireSession(sessionId);
759
+ if (canonicalDirectory(session.projectPath) !== this.workspaceValue) {
760
+ throw new errors_1.WebWorkbenchError(409, 'Session belongs to another workspace.');
761
+ }
762
+ const result = this.controllerValue.handle({
763
+ type: 'select_session',
764
+ sessionId: session.id,
765
+ source: 'programmatic',
766
+ });
767
+ await this.controllerValue.waitForIdle();
768
+ if (this.runtimeValue.getSession()?.id !== session.id) {
769
+ throw new errors_1.WebWorkbenchError(409, 'Session activation was rejected by the runtime.');
770
+ }
771
+ activated = true;
772
+ return { requestId: `activate:${session.id}`, result: result.type };
309
773
  }
310
- const result = this.controllerValue.handle({
311
- type: 'select_session',
312
- sessionId: session.id,
313
- source: 'programmatic',
314
- });
315
- await this.controllerValue.waitForIdle();
316
- if (this.runtimeValue.getSession()?.id !== session.id) {
317
- throw new WebWorkbenchError(409, 'Session activation was rejected by the runtime.');
774
+ finally {
775
+ this.suppressContextEdges -= 1;
776
+ this.sessionTransition = false;
777
+ if (activated)
778
+ this.emitState();
318
779
  }
319
- return { requestId: `activate:${session.id}`, result: result.type };
320
780
  }
321
- renameSession(sessionId, name) {
781
+ renameSession(sessionId, name, context) {
782
+ if (context)
783
+ this.assertContextGuard(context);
322
784
  const session = requireSession(sessionId);
323
785
  if (canonicalDirectory(session.projectPath) !== this.workspaceValue) {
324
- throw new WebWorkbenchError(409, 'Session belongs to another workspace.');
786
+ throw new errors_1.WebWorkbenchError(409, 'Session belongs to another workspace.');
325
787
  }
326
788
  if (name.length > 120)
327
- throw new WebWorkbenchError(400, 'Session name is too long.');
789
+ throw new errors_1.WebWorkbenchError(400, 'Session name is too long.');
328
790
  const updated = (0, session_storage_1.renameSession)(session.id, name);
329
791
  if (!updated)
330
- throw new WebWorkbenchError(404, 'Session no longer exists.');
792
+ throw new errors_1.WebWorkbenchError(404, 'Session no longer exists.');
331
793
  return (0, protocol_1.projectSessionSummary)(updated);
332
794
  }
333
- async switchWorkspace(path) {
334
- if (this.transition)
335
- throw new WebWorkbenchError(409, 'A workspace transition is running.');
795
+ async switchWorkspace(path, context) {
796
+ this.assertContextGuard(context);
336
797
  const next = canonicalDirectory(path);
337
798
  if (next === this.workspaceValue)
338
799
  return;
339
- this.assertReadyForTransition('switch workspaces');
800
+ let entry;
801
+ try {
802
+ entry = this.workspaceRegistry.register(next);
803
+ }
804
+ catch (error) {
805
+ throw mapWorkspaceRegistryError(error);
806
+ }
807
+ await this.activateContext({
808
+ expectedContextRevision: context.expectedContextRevision,
809
+ workspaceId: entry.id,
810
+ sessionId: null,
811
+ });
812
+ }
813
+ async activateContext(input) {
814
+ if (input.expectedContextRevision !== this.contextRevisionValue) {
815
+ throw new errors_1.WebWorkbenchError(409, 'The active Context changed before activation was admitted.', 'context_revision_conflict');
816
+ }
817
+ const entry = this.workspaceRegistry.find(input.workspaceId);
818
+ if (!entry)
819
+ throw new errors_1.WebWorkbenchError(404, 'Workspace was not found.', 'workspace_not_found');
820
+ const targetWorkspace = canonicalDirectory(entry.canonicalPath);
821
+ const targetSession = input.sessionId ? requireSession(input.sessionId) : null;
822
+ if (targetSession && canonicalDirectory(targetSession.projectPath) !== targetWorkspace) {
823
+ throw new errors_1.WebWorkbenchError(409, 'Session does not belong to the requested Workspace.', 'context_session_mismatch');
824
+ }
825
+ const currentSessionId = this.runtimeValue.getSession()?.id ?? null;
826
+ if (targetWorkspace === this.workspaceValue && targetSession?.id === currentSessionId)
827
+ return;
828
+ if (targetWorkspace === this.workspaceValue && !targetSession && currentSessionId === null)
829
+ return;
830
+ this.assertReadyForTransition('activate a Context');
831
+ const previousWorkspace = this.workspaceValue;
832
+ const previousSessionId = currentSessionId;
340
833
  const previousRuntime = this.runtimeValue;
341
834
  const previousController = this.controllerValue;
342
- const previousWorkspace = this.workspaceValue;
343
835
  const transition = (async () => {
344
- await previousController.stopActiveTurn();
345
- await previousController.waitForIdle();
346
- await previousRuntime.shutdown();
836
+ this.suppressContextEdges += 1;
347
837
  try {
348
- await this.installRuntime(next);
838
+ await previousController.stopActiveTurn();
839
+ await previousController.waitForIdle();
840
+ if (targetWorkspace !== previousWorkspace) {
841
+ await previousRuntime.shutdown();
842
+ await this.installRuntime(targetWorkspace, false);
843
+ }
844
+ await this.restoreContextSession(targetSession?.id ?? null);
845
+ this.workspaceRegistry.register(targetWorkspace, { activated: true });
349
846
  }
350
- catch (error) {
847
+ catch (activationError) {
351
848
  try {
352
- await this.installRuntime(previousWorkspace);
849
+ if (targetWorkspace !== previousWorkspace) {
850
+ if (this.runtimeValue !== previousRuntime)
851
+ await this.runtimeValue.shutdown();
852
+ await this.installRuntime(previousWorkspace, false);
853
+ }
854
+ await this.restoreContextSession(previousSessionId);
353
855
  }
354
856
  catch {
355
- throw new WebWorkbenchError(503, 'Workspace activation failed and the previous workspace could not be restored.');
857
+ throw new errors_1.WebWorkbenchError(503, 'Context activation failed and the previous Context could not be restored.', 'context_recovery_required');
356
858
  }
357
- throw error;
859
+ throw activationError;
358
860
  }
359
- const describe = this.runtimeValue.describeSettings;
360
- if (!describe) {
361
- throw new WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
362
- }
363
- const settings = describe();
364
- this.eventHub.emit({
365
- type: 'settings_invalidated',
366
- revision: settings.revision,
367
- reason: 'workspace-change',
368
- state: settings.state === 'ready' || settings.state === 'read-only' ? 'ready' : 'invalid',
369
- }, false);
861
+ finally {
862
+ this.suppressContextEdges -= 1;
863
+ }
864
+ this.emitState();
865
+ this.emitWorkspaceResourceInvalidation('context-change');
866
+ if (targetWorkspace !== previousWorkspace)
867
+ this.emitSettingsWorkspaceChange();
370
868
  })().finally(() => {
371
869
  if (this.transition === transition)
372
870
  this.transition = undefined;
@@ -376,20 +874,184 @@ class WebWorkbenchController {
376
874
  }
377
875
  dispatch(raw) {
378
876
  const command = (0, protocol_1.parseWebCommand)(raw);
379
- return this.executeMutation(command.requestId, 'command', command, () => {
380
- const runtimeResult = this.controllerValue.handle((0, protocol_1.toAgentRuntimeInput)(command));
877
+ return this.executeMutation(command.requestId, 'command', command, async () => {
878
+ this.assertCommandSession(command.expectedSessionId);
879
+ const resolved = await this.resolveCommandContext(command);
880
+ this.assertCommandSession(command.expectedSessionId);
881
+ const runtimeInput = resolved && command.type === 'queue_followup'
882
+ ? {
883
+ type: 'queue_followup',
884
+ text: command.text,
885
+ resolvedText: resolved.text,
886
+ source: 'programmatic',
887
+ }
888
+ : (0, protocol_1.toAgentRuntimeInput)({ ...command, ...(resolved ? { text: resolved.text } : {}) });
889
+ const runtimeResult = this.controllerValue.handle(runtimeInput);
381
890
  return Object.freeze({
382
891
  requestId: command.requestId,
383
892
  result: runtimeResult.type,
384
893
  detail: JSON.stringify(runtimeResult),
894
+ ...(resolved ? { contextReceipt: resolved.receipt } : {}),
385
895
  });
386
896
  });
387
897
  }
898
+ async resolveCommandContext(command) {
899
+ const references = command.contextReferences ?? [];
900
+ if (references.length === 0)
901
+ return undefined;
902
+ if (command.type !== 'submit' && command.type !== 'queue_followup') {
903
+ throw new errors_1.WebWorkbenchError(400, 'Context references are valid only for submitted or queued messages.', 'context_reference_invalid');
904
+ }
905
+ const resolved = [];
906
+ let totalBytes = 0;
907
+ for (const reference of references) {
908
+ let entry;
909
+ try {
910
+ entry = await this.resolveContextReference(reference);
911
+ }
912
+ catch (error) {
913
+ throw mapContextReferenceError(error);
914
+ }
915
+ const bytes = Buffer.byteLength(JSON.stringify(entry), 'utf8');
916
+ if (bytes > MAX_CONTEXT_REFERENCE_BYTES) {
917
+ throw new errors_1.WebWorkbenchError(413, 'A Context reference exceeds the per-reference byte budget.', 'context_reference_too_large');
918
+ }
919
+ totalBytes += bytes;
920
+ if (totalBytes > MAX_CONTEXT_MANIFEST_BYTES) {
921
+ throw new errors_1.WebWorkbenchError(413, 'Context references exceed the request byte budget.', 'context_reference_too_large');
922
+ }
923
+ resolved.push(Object.freeze(entry));
924
+ }
925
+ const manifestContent = Object.freeze({
926
+ version: 1,
927
+ references: Object.freeze(resolved),
928
+ });
929
+ const manifestDigest = (0, canonical_1.digestRuntimeValue)(manifestContent);
930
+ const manifest = Object.freeze({ ...manifestContent, manifestDigest });
931
+ return Object.freeze({
932
+ text: [
933
+ command.text.trim(),
934
+ '',
935
+ '[Orion Context Manifest V1]',
936
+ JSON.stringify(manifest),
937
+ ].join('\n'),
938
+ receipt: Object.freeze({
939
+ manifestDigest,
940
+ referenceCount: resolved.length,
941
+ totalBytes,
942
+ }),
943
+ });
944
+ }
945
+ async resolveContextReference(reference) {
946
+ switch (reference.kind) {
947
+ case 'file': {
948
+ const page = this.fileService.readContent({
949
+ fileId: reference.id,
950
+ limitBytes: MAX_CONTEXT_REFERENCE_BYTES,
951
+ });
952
+ if (page.revision !== reference.revision) {
953
+ throw new errors_1.WebWorkbenchError(409, 'Referenced file changed.', 'context_reference_stale');
954
+ }
955
+ if (page.binary || page.content === undefined) {
956
+ throw new errors_1.WebWorkbenchError(403, 'Binary files cannot be added to model Context.', 'context_reference_forbidden');
957
+ }
958
+ return {
959
+ kind: 'file',
960
+ id: reference.id,
961
+ label: this.sanitizeContextText(page.name),
962
+ revision: page.revision,
963
+ content: this.sanitizeContextText(page.content),
964
+ truncated: page.nextCursor !== null,
965
+ };
966
+ }
967
+ case 'folder': {
968
+ const page = this.fileService.list({ parentId: reference.id, pageSize: 100 });
969
+ if (page.revision !== reference.revision) {
970
+ throw new errors_1.WebWorkbenchError(409, 'Referenced folder changed.', 'context_reference_stale');
971
+ }
972
+ return {
973
+ kind: 'folder',
974
+ id: reference.id,
975
+ label: this.sanitizeContextText(reference.label),
976
+ revision: page.revision,
977
+ entries: page.items
978
+ .filter(item => !item.sensitive)
979
+ .map(item => ({
980
+ name: this.sanitizeContextText(item.name),
981
+ kind: item.kind,
982
+ sizeBytes: item.sizeBytes ?? null,
983
+ })),
984
+ truncated: page.nextCursor !== null,
985
+ };
986
+ }
987
+ case 'review': {
988
+ const snapshot = await this.reviewService.snapshot();
989
+ if (snapshot.repositoryRevision !== reference.gitRevision) {
990
+ throw new errors_1.WebWorkbenchError(409, 'Referenced Review changed.', 'context_reference_stale');
991
+ }
992
+ return {
993
+ kind: 'review',
994
+ id: reference.id,
995
+ revision: snapshot.repositoryRevision,
996
+ clean: snapshot.clean,
997
+ files: snapshot.changedFiles.slice(0, 100).map(file => ({
998
+ id: file.fileId,
999
+ path: this.sanitizeContextText(file.path),
1000
+ indexStatus: file.indexStatus,
1001
+ worktreeStatus: file.worktreeStatus,
1002
+ })),
1003
+ truncated: snapshot.truncated || snapshot.changedFiles.length > 100,
1004
+ };
1005
+ }
1006
+ case 'session': {
1007
+ const session = requireSession(reference.id);
1008
+ if (canonicalDirectory(session.projectPath) !== this.workspaceValue) {
1009
+ throw new errors_1.WebWorkbenchError(403, 'Referenced Session belongs to another Workspace.', 'context_reference_forbidden');
1010
+ }
1011
+ const summary = (0, protocol_1.projectSessionSummary)(session);
1012
+ if (summary.contextDigest !== reference.digest) {
1013
+ throw new errors_1.WebWorkbenchError(409, 'Referenced Session changed.', 'context_reference_stale');
1014
+ }
1015
+ const page = (0, thread_session_view_1.loadThreadSessionSnapshotPageV1)(this.workspaceValue, session.id, undefined, 8);
1016
+ return {
1017
+ kind: 'session',
1018
+ id: session.id,
1019
+ label: this.sanitizeContextText(session.name?.trim() || `Session ${session.id.slice(0, 8)}`),
1020
+ digest: summary.contextDigest,
1021
+ messages: (page?.transcript.items ?? []).map(message => ({
1022
+ role: message.role,
1023
+ content: this.sanitizeContextText(message.content),
1024
+ })),
1025
+ truncated: Boolean(page?.transcript.nextCursor),
1026
+ };
1027
+ }
1028
+ case 'skill': {
1029
+ const descriptor = ((await this.runtimeValue.inspectSkills?.()) ?? []).find(skill => skill.id === reference.id);
1030
+ if (!descriptor || descriptor.digest !== reference.digest) {
1031
+ throw new errors_1.WebWorkbenchError(409, 'Referenced Skill changed.', 'context_reference_stale');
1032
+ }
1033
+ if (!descriptor.userInvocable) {
1034
+ throw new errors_1.WebWorkbenchError(403, 'Referenced Skill is not user-invocable.', 'context_reference_forbidden');
1035
+ }
1036
+ return {
1037
+ kind: 'skill',
1038
+ id: descriptor.id,
1039
+ label: this.sanitizeContextText(descriptor.name),
1040
+ digest: descriptor.digest,
1041
+ invocation: `$${descriptor.id}`,
1042
+ description: this.sanitizeContextText(descriptor.description),
1043
+ };
1044
+ }
1045
+ }
1046
+ }
1047
+ sanitizeContextText(value) {
1048
+ return (0, redaction_1.redactTraceText)(value).split(this.workspaceValue).join('<workspace>');
1049
+ }
388
1050
  async updateSettings(input) {
389
1051
  this.assertSettingsAvailable();
390
1052
  const update = this.runtimeValue.updateSettings;
391
1053
  if (!update) {
392
- throw new WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
1054
+ throw new errors_1.WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
393
1055
  }
394
1056
  try {
395
1057
  const result = await update({
@@ -411,7 +1073,7 @@ class WebWorkbenchController {
411
1073
  this.assertSettingsAvailable();
412
1074
  const coordinator = this.runtimeValue.settingsCoordinator;
413
1075
  if (!coordinator) {
414
- throw new WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
1076
+ throw new errors_1.WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
415
1077
  }
416
1078
  try {
417
1079
  await coordinator.openDocument();
@@ -423,27 +1085,29 @@ class WebWorkbenchController {
423
1085
  }
424
1086
  executeMutation(requestId, operation, payload, action) {
425
1087
  if (!requestId.trim() || requestId.length > 128) {
426
- throw new WebWorkbenchError(400, 'requestId must be a non-empty string up to 128 characters.');
1088
+ throw new errors_1.WebWorkbenchError(400, 'requestId must be a non-empty string up to 128 characters.');
427
1089
  }
428
1090
  const fingerprint = (0, canonical_1.digestRuntimeValue)({ operation, payload });
429
1091
  const cached = this.mutationResults.get(requestId);
430
1092
  if (cached) {
431
1093
  if (cached.fingerprint !== fingerprint) {
432
- throw new WebWorkbenchError(409, 'requestId was already used for another mutation.', 'request_id_conflict');
1094
+ throw new errors_1.WebWorkbenchError(409, 'requestId was already used for another mutation.', 'request_id_conflict');
433
1095
  }
434
1096
  return cached.result;
435
1097
  }
436
- const result = Promise.resolve().then(action);
437
- this.mutationResults.set(requestId, { fingerprint, result });
438
- while (this.mutationResults.size > 512) {
439
- const oldest = this.mutationResults.keys().next().value;
440
- if (!oldest)
441
- break;
442
- this.mutationResults.delete(oldest);
1098
+ if (this.mutationResults.size >= MAX_MUTATION_RESULTS) {
1099
+ throw new errors_1.WebWorkbenchError(503, 'The mutation idempotency ledger is full; restart the local Web Host before retrying.', 'mutation_capacity_exhausted');
443
1100
  }
1101
+ const result = Promise.resolve().then(() => {
1102
+ this.assertMutationAdmission();
1103
+ return action();
1104
+ });
1105
+ this.mutationResults.set(requestId, { fingerprint, result });
444
1106
  return result;
445
1107
  }
446
- async diagnostics() {
1108
+ async diagnostics(context) {
1109
+ if (context)
1110
+ this.assertContextGuard(context);
447
1111
  const state = this.runtimeValue.store.getSnapshot();
448
1112
  const mcp = (0, mcp_1.loadFirstPartyMcpConfigurationV1)();
449
1113
  const activeSessionId = this.runtimeValue.getSession()?.id ?? null;
@@ -452,7 +1116,7 @@ class WebWorkbenchController {
452
1116
  const harness = activeSessionId
453
1117
  ? ((await this.runtimeValue.getHarnessDiagnostics?.()) ?? null)
454
1118
  : null;
455
- return {
1119
+ const result = {
456
1120
  workspace: this.workspaceValue,
457
1121
  activeSessionId,
458
1122
  configured: this.runtimeValue.isConfigured,
@@ -470,7 +1134,14 @@ class WebWorkbenchController {
470
1134
  mcp: { servers: mcpServerIds(mcp) },
471
1135
  harness,
472
1136
  eventStream: this.eventHub.snapshot(),
1137
+ performance: {
1138
+ files: this.fileService.performanceCounters(),
1139
+ git: this.gitService.performanceCounters(),
1140
+ },
473
1141
  };
1142
+ if (context)
1143
+ this.assertContextGuard(context);
1144
+ return result;
474
1145
  }
475
1146
  async shutdown() {
476
1147
  if (this.closed)
@@ -478,22 +1149,47 @@ class WebWorkbenchController {
478
1149
  this.closed = true;
479
1150
  await this.controllerValue.stopActiveTurn();
480
1151
  await this.controllerValue.waitForIdle();
1152
+ this.closeWorkspaceWatchers();
1153
+ this.closeComposerObserver();
1154
+ await this.terminalManager.shutdown();
481
1155
  await this.runtimeValue.shutdown();
482
1156
  this.eventHub.close();
483
1157
  }
484
- async installRuntime(workspace) {
1158
+ async installRuntime(workspace, publishState = true) {
1159
+ this.closeComposerObserver();
485
1160
  const runtime = await this.createRuntime(workspace);
1161
+ this.closeWorkspaceWatchers();
486
1162
  this.activeOrionRuntime = undefined;
487
1163
  this.sessionViews.clear();
488
1164
  this.sessionViewBytes = 0;
489
1165
  this.latestStatus = 'Ready';
490
1166
  this.workspaceValue = workspace;
491
1167
  this.runtimeValue = runtime;
1168
+ this.fileService = new file_read_service_1.FileReadServiceV1(workspace);
1169
+ this.gitService = new git_read_model_service_1.GitReadModelServiceV1(workspace);
1170
+ this.reviewService = new review_service_1.ReviewServiceV1(this.gitService, () => (0, durable_tool_receipt_reader_1.listProjectDurableToolReceiptRefsV1)(this.workspaceValue));
492
1171
  const eventSink = {
493
1172
  emit: (event) => {
494
1173
  if (event.type === 'status_changed')
495
1174
  this.latestStatus = event.message;
1175
+ if (this.suppressContextEdges > 0)
1176
+ return undefined;
496
1177
  const activeSessionId = this.runtimeValue.getSession()?.id;
1178
+ if (event.type === 'followup_queue_changed') {
1179
+ // The queue is part of the Composer CAS authority. Publishing the
1180
+ // Runtime queue event on its own lets the browser observe new items
1181
+ // with an older controlRevision and guarantees a 409 on its next
1182
+ // edit/move/remove action. Project one complete Composer state in
1183
+ // this same synchronous boundary instead. Composer actions suppress
1184
+ // their intermediate queue edge and publish their final state below.
1185
+ if (activeSessionId && this.suppressComposerEdges === 0) {
1186
+ const state = this.composerState(activeSessionId);
1187
+ this.eventHub.emit({ type: 'composer_state_changed', state }, true, {
1188
+ sessionId: activeSessionId,
1189
+ });
1190
+ }
1191
+ return undefined;
1192
+ }
497
1193
  const activeThread = activeSessionId && this.activeOrionRuntime?.sessionId === activeSessionId
498
1194
  ? this.activeOrionRuntime.runtime.thread.getProjection().threadId
499
1195
  : undefined;
@@ -503,6 +1199,9 @@ class WebWorkbenchController {
503
1199
  });
504
1200
  if (event.type === 'session_restored')
505
1201
  this.emitState();
1202
+ if (event.type === 'tool_finished') {
1203
+ this.scheduleWorkspaceResourceInvalidation('tool-finished');
1204
+ }
506
1205
  return event.type === 'transcript_append' ? `web-entry-${envelope.cursor}` : undefined;
507
1206
  },
508
1207
  };
@@ -522,14 +1221,157 @@ class WebWorkbenchController {
522
1221
  suppressAbortNotice: true,
523
1222
  },
524
1223
  });
525
- this.emitState();
1224
+ this.composerRevisionValue = (0, crypto_1.randomUUID)();
1225
+ this.composerAuthorityDigest = undefined;
1226
+ this.composerStoreUnsubscribe = runtime.store.subscribe(() => this.scheduleComposerStateChanged());
1227
+ this.composerControlsUnsubscribe = runtime.sessionComposerControls?.subscribe(() => this.scheduleComposerStateChanged());
1228
+ this.installWorkspaceWatchers();
1229
+ if (publishState)
1230
+ this.emitState(false);
526
1231
  }
527
- emitState() {
1232
+ emitState(advanceRevision = true) {
1233
+ if (this.suppressContextEdges > 0)
1234
+ return;
1235
+ if (advanceRevision)
1236
+ this.contextRevisionValue = (0, crypto_1.randomUUID)();
1237
+ if (advanceRevision)
1238
+ this.composerRevisionValue = (0, crypto_1.randomUUID)();
1239
+ const workspace = this.activeWorkspaceEntry();
1240
+ const sessionId = this.runtimeValue.getSession()?.id ?? null;
1241
+ if (sessionId) {
1242
+ try {
1243
+ this.composerAuthorityDigest = composerAuthorityDigest(this.projectComposerStateValue(requireSession(sessionId), true));
1244
+ }
1245
+ catch {
1246
+ this.composerAuthorityDigest = undefined;
1247
+ }
1248
+ }
1249
+ else {
1250
+ this.composerAuthorityDigest = undefined;
1251
+ }
528
1252
  this.eventHub.emit({
529
1253
  type: 'workbench_state',
1254
+ contextRevision: this.contextRevisionValue,
1255
+ workspaceId: workspace.id,
530
1256
  workspace: this.workspaceValue,
531
- activeSessionId: this.runtimeValue.getSession()?.id ?? null,
1257
+ activeSessionId: sessionId,
1258
+ });
1259
+ }
1260
+ emitWorkspaceResourceInvalidation(reason) {
1261
+ if (this.closed || this.suppressContextEdges > 0)
1262
+ return;
1263
+ this.eventHub.emit({
1264
+ type: 'workspace_resource_invalidated',
1265
+ workspaceId: this.activeWorkspaceEntry().id,
1266
+ resources: ['files', 'git', 'review'],
1267
+ reason,
1268
+ }, false);
1269
+ }
1270
+ scheduleWorkspaceResourceInvalidation(reason) {
1271
+ if (this.closed)
1272
+ return;
1273
+ if (this.resourceInvalidationTimer)
1274
+ clearTimeout(this.resourceInvalidationTimer);
1275
+ const workspace = this.workspaceValue;
1276
+ this.resourceInvalidationTimer = setTimeout(() => {
1277
+ this.resourceInvalidationTimer = undefined;
1278
+ if (!this.closed && this.workspaceValue === workspace) {
1279
+ this.emitWorkspaceResourceInvalidation(reason);
1280
+ }
1281
+ }, 120);
1282
+ this.resourceInvalidationTimer.unref();
1283
+ }
1284
+ bumpComposerRevision() {
1285
+ this.composerRevisionValue = (0, crypto_1.randomUUID)();
1286
+ }
1287
+ scheduleComposerStateChanged() {
1288
+ if (this.closed ||
1289
+ this.suppressComposerEdges > 0 ||
1290
+ this.suppressContextEdges > 0 ||
1291
+ !this.runtimeValue.getSession()) {
1292
+ return;
1293
+ }
1294
+ if (this.composerChangeTimer)
1295
+ clearTimeout(this.composerChangeTimer);
1296
+ this.composerChangeTimer = setTimeout(() => {
1297
+ this.composerChangeTimer = undefined;
1298
+ const sessionId = this.runtimeValue.getSession()?.id;
1299
+ if (!sessionId || this.closed || this.suppressComposerEdges > 0)
1300
+ return;
1301
+ try {
1302
+ const state = this.composerState(sessionId);
1303
+ this.eventHub.emit({ type: 'composer_state_changed', state }, true, { sessionId });
1304
+ }
1305
+ catch {
1306
+ // A concurrent Context transition publishes a fresh baseline instead.
1307
+ }
1308
+ }, 100);
1309
+ this.composerChangeTimer.unref();
1310
+ }
1311
+ closeComposerObserver() {
1312
+ this.composerStoreUnsubscribe?.();
1313
+ this.composerStoreUnsubscribe = undefined;
1314
+ this.composerControlsUnsubscribe?.();
1315
+ this.composerControlsUnsubscribe = undefined;
1316
+ if (this.composerChangeTimer)
1317
+ clearTimeout(this.composerChangeTimer);
1318
+ this.composerChangeTimer = undefined;
1319
+ }
1320
+ installWorkspaceWatchers() {
1321
+ const watchPaths = [this.workspaceValue, (0, path_1.resolve)(this.workspaceValue, '.git')].filter(path => (0, fs_1.existsSync)(path));
1322
+ for (const path of watchPaths) {
1323
+ try {
1324
+ const watcher = (0, fs_1.watch)(path, { persistent: false }, () => this.scheduleWorkspaceResourceInvalidation('filesystem-change'));
1325
+ watcher.on('error', () => {
1326
+ watcher.close();
1327
+ this.workspaceWatchers = this.workspaceWatchers.filter(item => item !== watcher);
1328
+ });
1329
+ this.workspaceWatchers.push(watcher);
1330
+ }
1331
+ catch {
1332
+ // Watchers are only refresh hints. Revisioned reads and explicit refresh remain authoritative.
1333
+ }
1334
+ }
1335
+ }
1336
+ closeWorkspaceWatchers() {
1337
+ if (this.resourceInvalidationTimer)
1338
+ clearTimeout(this.resourceInvalidationTimer);
1339
+ this.resourceInvalidationTimer = undefined;
1340
+ for (const watcher of this.workspaceWatchers)
1341
+ watcher.close();
1342
+ this.workspaceWatchers = [];
1343
+ }
1344
+ async restoreContextSession(sessionId) {
1345
+ if (!sessionId) {
1346
+ this.runtimeValue.setSession(null);
1347
+ await this.runtimeValue.rebindSessionRuntime?.();
1348
+ if (this.suppressContextEdges === 0) {
1349
+ this.eventHub.emitRuntime({ type: 'transcript_clear' });
1350
+ }
1351
+ return;
1352
+ }
1353
+ const result = this.controllerValue.handle({
1354
+ type: 'select_session',
1355
+ sessionId,
1356
+ source: 'programmatic',
532
1357
  });
1358
+ await this.controllerValue.waitForIdle();
1359
+ if (this.runtimeValue.getSession()?.id !== sessionId) {
1360
+ throw new errors_1.WebWorkbenchError(409, `Session activation was rejected by the runtime (${result.type}).`, 'context_session_rejected');
1361
+ }
1362
+ }
1363
+ emitSettingsWorkspaceChange() {
1364
+ const describe = this.runtimeValue.describeSettings;
1365
+ if (!describe) {
1366
+ throw new errors_1.WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
1367
+ }
1368
+ const settings = describe();
1369
+ this.eventHub.emit({
1370
+ type: 'settings_invalidated',
1371
+ revision: settings.revision,
1372
+ reason: 'workspace-change',
1373
+ state: settings.state === 'ready' || settings.state === 'read-only' ? 'ready' : 'invalid',
1374
+ }, false);
533
1375
  }
534
1376
  observeThreadRuntime(runtime, sessionId, activation) {
535
1377
  let replaying = true;
@@ -612,31 +1454,138 @@ class WebWorkbenchController {
612
1454
  }
613
1455
  assertReadyForTransition(operation) {
614
1456
  if (this.closed)
615
- throw new WebWorkbenchError(503, 'Web Workbench is closed.');
616
- if (this.transition) {
617
- throw new WebWorkbenchError(409, 'A workspace transition is running.', 'runtime_busy');
1457
+ throw new errors_1.WebWorkbenchError(503, 'Web Workbench is closed.');
1458
+ if (this.transition || this.sessionTransition) {
1459
+ throw new errors_1.WebWorkbenchError(409, 'A workspace transition is running.', 'runtime_busy');
618
1460
  }
619
1461
  if (this.controllerValue.hasActiveTurn()) {
620
- throw new WebWorkbenchError(409, `Cannot ${operation} while a turn is active.`, 'runtime_busy');
1462
+ throw new errors_1.WebWorkbenchError(409, `Cannot ${operation} while a turn is active.`, 'runtime_busy');
1463
+ }
1464
+ }
1465
+ assertMutationAdmission() {
1466
+ if (this.closed)
1467
+ throw new errors_1.WebWorkbenchError(503, 'Web Workbench is closed.');
1468
+ if (this.transition || this.sessionTransition) {
1469
+ throw new errors_1.WebWorkbenchError(409, 'A Context transition is running.', 'runtime_busy');
1470
+ }
1471
+ }
1472
+ assertContextGuard(context) {
1473
+ this.assertMutationAdmission();
1474
+ if (context.expectedContextRevision !== this.contextRevisionValue ||
1475
+ context.workspaceId !== this.activeWorkspaceEntry().id) {
1476
+ throw new errors_1.WebWorkbenchError(409, 'The active Context changed before the operation was admitted.', 'context_revision_conflict');
1477
+ }
1478
+ }
1479
+ assertCommandSession(expectedSessionId) {
1480
+ if (this.closed)
1481
+ throw new errors_1.WebWorkbenchError(503, 'Web Workbench is closed.');
1482
+ if (this.transition || this.sessionTransition) {
1483
+ throw new errors_1.WebWorkbenchError(409, 'A Session transition is running.', 'runtime_busy');
1484
+ }
1485
+ if (this.runtimeValue.getSession()?.id !== expectedSessionId) {
1486
+ throw new errors_1.WebWorkbenchError(409, 'The active Session changed before the command was admitted.', 'active_session_changed');
621
1487
  }
622
1488
  }
623
1489
  assertSettingsAvailable() {
624
1490
  if (this.closed) {
625
- throw new WebWorkbenchError(503, 'Web Workbench is closed.', 'settings_document_unavailable');
1491
+ throw new errors_1.WebWorkbenchError(503, 'Web Workbench is closed.', 'settings_document_unavailable');
626
1492
  }
627
1493
  if (this.transition) {
628
- throw new WebWorkbenchError(409, 'A workspace transition is running.', 'runtime_busy');
1494
+ throw new errors_1.WebWorkbenchError(409, 'A workspace transition is running.', 'runtime_busy');
1495
+ }
1496
+ }
1497
+ initializeWorkspaceRegistry() {
1498
+ const paths = [this.workspaceValue];
1499
+ for (const session of (0, session_storage_1.listSessions)()) {
1500
+ try {
1501
+ paths.push(canonicalDirectory(session.projectPath));
1502
+ }
1503
+ catch {
1504
+ // Unavailable historical projects remain in the Session catalog but
1505
+ // are not imported as selectable Workspace registry entries.
1506
+ }
1507
+ }
1508
+ try {
1509
+ this.workspaceRegistry.registerKnown(paths, this.workspaceValue);
1510
+ }
1511
+ catch (error) {
1512
+ throw mapWorkspaceRegistryError(error);
1513
+ }
1514
+ }
1515
+ activeWorkspaceEntry() {
1516
+ const entry = this.workspaceRegistry
1517
+ .list()
1518
+ .find(candidate => candidate.canonicalPath === this.workspaceValue);
1519
+ if (!entry) {
1520
+ throw new errors_1.WebWorkbenchError(503, 'Active Workspace is missing from the registry.');
629
1521
  }
1522
+ return entry;
630
1523
  }
631
1524
  }
632
1525
  exports.WebWorkbenchController = WebWorkbenchController;
1526
+ function mapWorkspaceRegistryError(error) {
1527
+ if (error instanceof errors_1.WebWorkbenchError)
1528
+ return error;
1529
+ if (error instanceof workspace_registry_1.WorkspaceRegistryError) {
1530
+ const status = error.code === 'workspace_registry_invalid' ? 503 : 409;
1531
+ return new errors_1.WebWorkbenchError(status, error.message, error.code);
1532
+ }
1533
+ return new errors_1.WebWorkbenchError(503, 'Workspace registry is unavailable.');
1534
+ }
633
1535
  function mapSettingsError(error) {
634
- if (error instanceof WebWorkbenchError)
1536
+ if (error instanceof errors_1.WebWorkbenchError)
635
1537
  return error;
636
1538
  if (error instanceof settings_coordinator_1.SettingsCoordinatorError) {
637
- return new WebWorkbenchError(error.status, error.message, error.code);
1539
+ return new errors_1.WebWorkbenchError(error.status, error.message, error.code);
638
1540
  }
639
- return new WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
1541
+ return new errors_1.WebWorkbenchError(503, 'The product Settings coordinator is unavailable.', 'settings_document_unavailable');
1542
+ }
1543
+ function mapComposerControlError(error) {
1544
+ if (error instanceof errors_1.WebWorkbenchError)
1545
+ return error;
1546
+ if (error instanceof agent_runtime_controller_1.FollowupQueueConflictError) {
1547
+ return new errors_1.WebWorkbenchError(409, error.message, error.code);
1548
+ }
1549
+ if (error instanceof session_composer_control_1.SessionComposerControlError) {
1550
+ const status = error.code === 'composer_recovery_required'
1551
+ ? 503
1552
+ : ['runtime_busy', 'composer_control_conflict'].includes(error.code)
1553
+ ? 409
1554
+ : 422;
1555
+ return new errors_1.WebWorkbenchError(status, error.message, error.code);
1556
+ }
1557
+ if (error instanceof plan_review_1.PlanReviewControlError) {
1558
+ const status = error.code === 'plan_review_invalid' ? 422 : 409;
1559
+ return new errors_1.WebWorkbenchError(status, error.message, error.code);
1560
+ }
1561
+ return new errors_1.WebWorkbenchError(503, 'The Session Composer control action failed.', 'composer_control_failed');
1562
+ }
1563
+ function mapContextReferenceError(error) {
1564
+ if (error instanceof errors_1.WebWorkbenchError) {
1565
+ if (error.code.startsWith('context_reference_'))
1566
+ return error;
1567
+ if (error.status === 403 ||
1568
+ ['sensitive_file_blocked', 'file_binary', 'file_not_regular'].includes(error.code)) {
1569
+ return new errors_1.WebWorkbenchError(403, 'The Context reference is not available to the model.', 'context_reference_forbidden');
1570
+ }
1571
+ if (error.status === 404 || error.status === 409) {
1572
+ return new errors_1.WebWorkbenchError(409, 'The Context reference changed or is no longer available.', 'context_reference_stale');
1573
+ }
1574
+ }
1575
+ return new errors_1.WebWorkbenchError(422, 'The Context reference could not be resolved.', 'context_reference_invalid');
1576
+ }
1577
+ function composerAuthorityDigest(state) {
1578
+ return (0, canonical_1.digestRuntimeValue)({
1579
+ workspaceId: state.workspaceId,
1580
+ sessionId: state.sessionId,
1581
+ mode: state.mode,
1582
+ model: state.model,
1583
+ permission: state.permission,
1584
+ pending: state.pending,
1585
+ lastError: state.lastError,
1586
+ planReview: state.planReview,
1587
+ queue: state.queue,
1588
+ });
640
1589
  }
641
1590
  function pickPlanReceipt(receipt) {
642
1591
  return Object.freeze({
@@ -687,11 +1636,11 @@ function estimateSessionViewBytes(view) {
687
1636
  }
688
1637
  function pageItems(items, cursor, pageSize = 50) {
689
1638
  if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) {
690
- throw new WebWorkbenchError(400, 'pageSize must be an integer from 1 through 100.');
1639
+ throw new errors_1.WebWorkbenchError(400, 'pageSize must be an integer from 1 through 100.');
691
1640
  }
692
1641
  const offset = cursor ? decodePageCursor(cursor) : 0;
693
1642
  if (offset > items.length)
694
- throw new WebWorkbenchError(400, 'Page cursor is out of range.');
1643
+ throw new errors_1.WebWorkbenchError(400, 'Page cursor is out of range.');
695
1644
  const page = items.slice(offset, offset + pageSize);
696
1645
  const nextOffset = offset + page.length;
697
1646
  return Object.freeze({
@@ -699,13 +1648,45 @@ function pageItems(items, cursor, pageSize = 50) {
699
1648
  nextCursor: nextOffset < items.length ? encodePageCursor(nextOffset) : null,
700
1649
  });
701
1650
  }
1651
+ function pageCollectionItems(scope, items, cursor, pageSize, keyOf) {
1652
+ if (!scope || scope.length > 80 || !/^[a-z][a-z0-9_-]*$/.test(scope)) {
1653
+ throw new errors_1.WebWorkbenchError(500, 'Collection scope is invalid.');
1654
+ }
1655
+ if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) {
1656
+ throw new errors_1.WebWorkbenchError(400, 'pageSize must be an integer from 1 through 100.');
1657
+ }
1658
+ const keys = items.map(keyOf);
1659
+ if (keys.some(key => !key || key.length > 4096) || new Set(keys).size !== keys.length) {
1660
+ throw new errors_1.WebWorkbenchError(500, `Collection ${scope} has invalid or duplicate keys.`);
1661
+ }
1662
+ const revision = (0, canonical_1.digestRuntimeValue)(items);
1663
+ let offset = 0;
1664
+ if (cursor) {
1665
+ const decoded = decodeCollectionCursor(cursor, scope);
1666
+ if (decoded.revision !== revision) {
1667
+ throw new errors_1.WebWorkbenchError(409, 'Collection changed after this page cursor was issued.', 'collection_cursor_stale');
1668
+ }
1669
+ offset = keys.indexOf(decoded.after) + 1;
1670
+ if (offset === 0) {
1671
+ throw new errors_1.WebWorkbenchError(409, 'Collection page boundary no longer exists.', 'collection_cursor_stale');
1672
+ }
1673
+ }
1674
+ const page = items.slice(offset, offset + pageSize);
1675
+ const nextOffset = offset + page.length;
1676
+ return Object.freeze({
1677
+ items: Object.freeze(page),
1678
+ nextCursor: nextOffset < items.length && page.length > 0
1679
+ ? encodeCollectionCursor(scope, revision, keys[nextOffset - 1])
1680
+ : null,
1681
+ });
1682
+ }
702
1683
  function pageTranscriptTail(items, cursor, pageSize = 50) {
703
1684
  if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) {
704
- throw new WebWorkbenchError(400, 'pageSize must be an integer from 1 through 100.');
1685
+ throw new errors_1.WebWorkbenchError(400, 'pageSize must be an integer from 1 through 100.');
705
1686
  }
706
1687
  const end = cursor ? decodePageCursor(cursor) : items.length;
707
1688
  if (end > items.length)
708
- throw new WebWorkbenchError(400, 'Page cursor is out of range.');
1689
+ throw new errors_1.WebWorkbenchError(400, 'Page cursor is out of range.');
709
1690
  const offset = Math.max(0, end - pageSize);
710
1691
  return Object.freeze({
711
1692
  items: Object.freeze(items.slice(offset, end)),
@@ -716,9 +1697,37 @@ function pageTranscriptTail(items, cursor, pageSize = 50) {
716
1697
  function encodePageCursor(offset) {
717
1698
  return Buffer.from(JSON.stringify({ version: 1, offset })).toString('base64url');
718
1699
  }
1700
+ function encodeCollectionCursor(scope, revision, after) {
1701
+ return Buffer.from(JSON.stringify({ version: 2, kind: 'collection', scope, revision, after })).toString('base64url');
1702
+ }
1703
+ function decodeCollectionCursor(cursor, expectedScope) {
1704
+ if (!cursor || cursor.length > 8192) {
1705
+ throw new errors_1.WebWorkbenchError(400, 'Collection page cursor is invalid.');
1706
+ }
1707
+ try {
1708
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
1709
+ if (!parsed ||
1710
+ typeof parsed !== 'object' ||
1711
+ Array.isArray(parsed) ||
1712
+ parsed.version !== 2 ||
1713
+ parsed.kind !== 'collection' ||
1714
+ parsed.scope !== expectedScope ||
1715
+ typeof parsed.revision !== 'string' ||
1716
+ typeof parsed.after !== 'string') {
1717
+ throw new Error('invalid');
1718
+ }
1719
+ return {
1720
+ revision: parsed.revision,
1721
+ after: parsed.after,
1722
+ };
1723
+ }
1724
+ catch {
1725
+ throw new errors_1.WebWorkbenchError(400, 'Collection page cursor is invalid.');
1726
+ }
1727
+ }
719
1728
  function decodePageCursor(cursor) {
720
1729
  if (!cursor || cursor.length > 512)
721
- throw new WebWorkbenchError(400, 'Page cursor is invalid.');
1730
+ throw new errors_1.WebWorkbenchError(400, 'Page cursor is invalid.');
722
1731
  try {
723
1732
  const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
724
1733
  if (!parsed ||
@@ -731,7 +1740,7 @@ function decodePageCursor(cursor) {
731
1740
  return parsed.offset;
732
1741
  }
733
1742
  catch {
734
- throw new WebWorkbenchError(400, 'Page cursor is invalid.');
1743
+ throw new errors_1.WebWorkbenchError(400, 'Page cursor is invalid.');
735
1744
  }
736
1745
  }
737
1746
  function canonicalDirectory(path) {
@@ -741,26 +1750,26 @@ function canonicalDirectory(path) {
741
1750
  canonical = (0, fs_1.realpathSync)(candidate);
742
1751
  }
743
1752
  catch {
744
- throw new WebWorkbenchError(400, 'Workspace path does not exist.');
1753
+ throw new errors_1.WebWorkbenchError(400, 'Workspace path does not exist.');
745
1754
  }
746
1755
  try {
747
1756
  if (!(0, fs_1.statSync)(canonical).isDirectory()) {
748
- throw new WebWorkbenchError(400, 'Workspace path must be a directory.');
1757
+ throw new errors_1.WebWorkbenchError(400, 'Workspace path must be a directory.');
749
1758
  }
750
1759
  }
751
1760
  catch (error) {
752
- if (error instanceof WebWorkbenchError)
1761
+ if (error instanceof errors_1.WebWorkbenchError)
753
1762
  throw error;
754
- throw new WebWorkbenchError(400, 'Workspace path is not readable.');
1763
+ throw new errors_1.WebWorkbenchError(400, 'Workspace path is not readable.');
755
1764
  }
756
1765
  return canonical;
757
1766
  }
758
1767
  function requireSession(sessionId) {
759
1768
  if (!sessionId.trim())
760
- throw new WebWorkbenchError(400, 'Session id is required.');
1769
+ throw new errors_1.WebWorkbenchError(400, 'Session id is required.');
761
1770
  const session = (0, session_storage_1.loadSessionMeta)(sessionId);
762
1771
  if (!session)
763
- throw new WebWorkbenchError(404, 'Session was not found.');
1772
+ throw new errors_1.WebWorkbenchError(404, 'Session was not found.');
764
1773
  return session;
765
1774
  }
766
1775
  function mcpServerIds(config) {