@orion-agents/orion-code 0.3.11 → 0.3.14

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 (37) hide show
  1. package/CHANGELOG.md +94 -2
  2. package/README.md +8 -5
  3. package/README.zh-CN.md +5 -4
  4. package/dist/cli.js +14 -1
  5. package/dist/web/context-text.d.ts +17 -0
  6. package/dist/web/context-text.js +40 -0
  7. package/dist/web/file-read-service.d.ts +35 -0
  8. package/dist/web/file-read-service.js +158 -1
  9. package/dist/web/file-write-service.d.ts +16 -0
  10. package/dist/web/file-write-service.js +103 -0
  11. package/dist/web/git-read-model-service.d.ts +14 -0
  12. package/dist/web/git-read-model-service.js +64 -0
  13. package/dist/web/host-daemon.d.ts +9 -0
  14. package/dist/web/host-daemon.js +45 -1
  15. package/dist/web/protocol.d.ts +9 -2
  16. package/dist/web/protocol.js +26 -0
  17. package/dist/web/review-service.d.ts +2 -19
  18. package/dist/web/review-service.js +4 -39
  19. package/dist/web/server.js +111 -0
  20. package/dist/web/workbench-controller.d.ts +47 -0
  21. package/dist/web/workbench-controller.js +93 -13
  22. package/dist/web-client/assets/{DiffViewer-BrE5tfZc.js → DiffViewer-BrHqudpK.js} +1 -1
  23. package/dist/web-client/assets/FilesPanel-BcFY6L1a.js +1 -0
  24. package/dist/web-client/assets/GitPanel-B6E5IEXm.js +1 -0
  25. package/dist/web-client/assets/ResourceSplitLayout-fPYE9JKJ.js +1 -0
  26. package/dist/web-client/assets/ReviewPanel-5ecTVmhT.js +1 -0
  27. package/dist/web-client/assets/{TerminalPanel-CMLU7j08.js → TerminalPanel-IkCttxgw.js} +1 -1
  28. package/dist/web-client/assets/index-DgVNIqmE.js +20 -0
  29. package/dist/web-client/assets/index-R6E589Fc.css +1 -0
  30. package/dist/web-client/index.html +2 -2
  31. package/npm-shrinkwrap.json +2 -2
  32. package/package.json +1 -1
  33. package/dist/web-client/assets/FilesPanel-ZwlVgxJi.js +0 -2
  34. package/dist/web-client/assets/GitPanel-Cnz52VRO.js +0 -1
  35. package/dist/web-client/assets/ReviewPanel-D_xxkVG_.js +0 -1
  36. package/dist/web-client/assets/index-BlFUf8dG.js +0 -17
  37. package/dist/web-client/assets/index-CUG6w669.css +0 -1
@@ -262,6 +262,56 @@ class GitReadModelServiceV1 {
262
262
  this.pathById.set(id, path);
263
263
  return id;
264
264
  }
265
+ /** v0.3.12 S3 — guarded stage: paths are host-resolved file ids only. */
266
+ async stagePaths(paths) {
267
+ assertSafeGitPaths(paths);
268
+ if (paths.length === 0)
269
+ throw new Error('stage requires at least one path.');
270
+ await this.mutate(['add', '--', ...paths]);
271
+ return { repositoryRevision: await this.revisionAfterMutation() };
272
+ }
273
+ async unstagePaths(paths) {
274
+ assertSafeGitPaths(paths);
275
+ if (paths.length === 0)
276
+ throw new Error('unstage requires at least one path.');
277
+ await this.mutate(['reset', '-q', '--', ...paths]);
278
+ return { repositoryRevision: await this.revisionAfterMutation() };
279
+ }
280
+ async commit(message) {
281
+ const trimmed = message.trim();
282
+ if (!trimmed || trimmed.length > 2000 || /[\x00-\x1f]/.test(trimmed)) {
283
+ throw new Error('A valid commit message is required.');
284
+ }
285
+ const root = await this.requireRepositoryRoot();
286
+ const porcelain = (await this.runGit(['status', '--porcelain=v1'], root)).trim();
287
+ const stagedLines = porcelain
288
+ .split('\n')
289
+ .filter(line => line.length > 1 && line[0] !== ' ' && line[0] !== '?' && line[0] !== '!');
290
+ if (stagedLines.length === 0) {
291
+ throw new Error('Nothing is staged to commit.');
292
+ }
293
+ await this.mutate(['commit', '-m', trimmed]);
294
+ const sha = (await this.runGit(['rev-parse', 'HEAD'], root)).trim();
295
+ return { repositoryRevision: await this.revisionAfterMutation(), commitSha: sha };
296
+ }
297
+ async mutate(args) {
298
+ const root = await this.requireRepositoryRoot();
299
+ const output = await this.runGit(args, root);
300
+ if (output) {
301
+ // Warnings on stderr are not captured by runGit; treat non-empty as unexpected.
302
+ }
303
+ }
304
+ async requireRepositoryRoot() {
305
+ const snapshot = await this.capture();
306
+ if (!snapshot.isRepository || !snapshot.root) {
307
+ throw new Error('Git repository is unavailable.');
308
+ }
309
+ return snapshot.root;
310
+ }
311
+ async revisionAfterMutation() {
312
+ const root = await this.requireRepositoryRoot();
313
+ return this.runGit(['rev-parse', 'HEAD'], root).then(value => value.trim());
314
+ }
265
315
  runGit(args, cwd) {
266
316
  return new Promise((resolvePromise, reject) => {
267
317
  this.performance.processCount += 1;
@@ -590,6 +640,20 @@ function streamGitLines(input) {
590
640
  });
591
641
  });
592
642
  }
643
+ function assertSafeGitPaths(paths) {
644
+ for (const path of paths) {
645
+ if (typeof path !== 'string' ||
646
+ path.length === 0 ||
647
+ path.length > 4096 ||
648
+ path.includes('\0') ||
649
+ path.startsWith('-') ||
650
+ path.includes('/../') ||
651
+ path === '..' ||
652
+ path.startsWith('../')) {
653
+ throw new Error(`Unsafe Git path rejected: ${JSON.stringify(path)}`);
654
+ }
655
+ }
656
+ }
593
657
  function gitEnvironment() {
594
658
  return {
595
659
  PATH: process.env.PATH,
@@ -48,6 +48,15 @@ export declare function hostDaemonStatus(port: number): HostDaemonStatus;
48
48
  * stdio to `~/.orion-code/logs/web-<port>.log`, and wait for the pidfile so the
49
49
  * foreground command can print the URL and exit cleanly.
50
50
  */
51
+ /**
52
+ * True when something already listens on 127.0.0.1:port — typically another
53
+ * Orion host process (e.g. one managed by launchd, which does not write a
54
+ * pidfile). Detecting this up front turns a raw EADDRINUSE crash into a
55
+ * friendly message.
56
+ */
57
+ export declare function isHostPortTaken(port: number): Promise<boolean>;
58
+ /** Best-effort tail of the host log used when a background child exits early. */
59
+ export declare function readHostLogTail(port: number, lines?: number): string;
51
60
  export declare function spawnBackgroundHost(options: {
52
61
  readonly cwd: string;
53
62
  readonly port: number;
@@ -9,6 +9,8 @@ exports.writeHostPidfile = writeHostPidfile;
9
9
  exports.clearHostPidfile = clearHostPidfile;
10
10
  exports.isProcessAlive = isProcessAlive;
11
11
  exports.hostDaemonStatus = hostDaemonStatus;
12
+ exports.isHostPortTaken = isHostPortTaken;
13
+ exports.readHostLogTail = readHostLogTail;
12
14
  exports.spawnBackgroundHost = spawnBackgroundHost;
13
15
  exports.stopBackgroundHost = stopBackgroundHost;
14
16
  /**
@@ -26,6 +28,7 @@ exports.stopBackgroundHost = stopBackgroundHost;
26
28
  * "startedAt": 1725000000000, "workspace": "/abs/path" }
27
29
  */
28
30
  const child_process_1 = require("child_process");
31
+ const net_1 = require("net");
29
32
  const fs_1 = require("fs");
30
33
  const path_1 = require("path");
31
34
  const paths_1 = require("../product/paths");
@@ -119,7 +122,48 @@ function childArgs(webArgs) {
119
122
  * stdio to `~/.orion-code/logs/web-<port>.log`, and wait for the pidfile so the
120
123
  * foreground command can print the URL and exit cleanly.
121
124
  */
122
- function spawnBackgroundHost(options) {
125
+ /**
126
+ * True when something already listens on 127.0.0.1:port — typically another
127
+ * Orion host process (e.g. one managed by launchd, which does not write a
128
+ * pidfile). Detecting this up front turns a raw EADDRINUSE crash into a
129
+ * friendly message.
130
+ */
131
+ function isHostPortTaken(port) {
132
+ return new Promise(resolve => {
133
+ const socket = (0, net_1.connect)({ host: '127.0.0.1', port });
134
+ let settled = false;
135
+ const done = (taken) => {
136
+ if (settled)
137
+ return;
138
+ settled = true;
139
+ socket.destroy();
140
+ resolve(taken);
141
+ };
142
+ socket.once('connect', () => done(true));
143
+ socket.once('error', () => done(false));
144
+ socket.setTimeout(800, () => done(false));
145
+ });
146
+ }
147
+ /** Best-effort tail of the host log used when a background child exits early. */
148
+ function readHostLogTail(port, lines = 6) {
149
+ try {
150
+ const raw = (0, fs_1.readFileSync)((0, path_1.join)(hostLogsDirectory(), `web-${port}.log`), 'utf8');
151
+ return raw.split('\n').filter(Boolean).slice(-lines).join('\n');
152
+ }
153
+ catch {
154
+ return '';
155
+ }
156
+ }
157
+ async function assertHostPortAvailable(port) {
158
+ if (await isHostPortTaken(port)) {
159
+ throw new Error(`Port ${port} is already served by another Orion host process. This is ` +
160
+ `often a launchd LaunchAgent (ai.orion-code.web). Check with ` +
161
+ '`launchctl list | grep orion`; if the running host belongs to this ' +
162
+ `CLI, use \`orion web status --port ${port}\` to confirm before stopping it.`);
163
+ }
164
+ }
165
+ async function spawnBackgroundHost(options) {
166
+ await assertHostPortAvailable(options.port);
123
167
  const logsDirectory = hostLogsDirectory();
124
168
  (0, fs_1.mkdirSync)(logsDirectory, { recursive: true });
125
169
  const logPath = (0, path_1.join)(logsDirectory, `web-${options.port}.log`);
@@ -9,7 +9,7 @@ import type { SessionMeta } from '../services/session-storage';
9
9
  import type { ToolConfirmationPolicy } from '../services/global-config';
10
10
  import type { WebFileContentPageV1, WebFileNodeV1, WebFileTreePageV1 } from './file-read-service';
11
11
  import type { WebGitCommitV1, WebGitDiffPageV1, WebGitFileV1, WebGitLogPageV1, WebGitStatusV1 } from './git-read-model-service';
12
- import type { WebReviewSnapshotV1, WebReviewVerificationV1 } from './review-service';
12
+ import type { WebReviewSnapshotV1 } from './review-service';
13
13
  import type { WebSessionRuntimeSummaryV1 } from './session-runtime-registry';
14
14
  import type { WebTerminalCreateResultV1, WebTerminalExitV1, WebTerminalGapV1, WebTerminalMetadataV1, WebTerminalOutputFrameV1, WebTerminalStateV1 } from './terminal-manager';
15
15
  export declare const WEB_API_VERSION: 1;
@@ -171,6 +171,13 @@ export type WebContextReferenceV1 = {
171
171
  readonly id: string;
172
172
  readonly label: string;
173
173
  readonly revision: string;
174
+ } | {
175
+ readonly kind: 'file_range';
176
+ readonly id: string;
177
+ readonly label: string;
178
+ readonly revision: string;
179
+ readonly startLine: number;
180
+ readonly endLine: number;
174
181
  } | {
175
182
  readonly kind: 'folder';
176
183
  readonly id: string;
@@ -369,7 +376,7 @@ export type WebWorkspaceResourceV1 = 'files' | 'git' | 'review';
369
376
  export type WebWorkspaceInvalidationReasonV1 = 'context-change' | 'filesystem-change' | 'terminal-command' | 'tool-finished';
370
377
  export type WebToolDetailSummaryV1 = ToolDetailSummary;
371
378
  export type WebToolDetailPageV1 = ToolDetailPage;
372
- export type { WebFileContentPageV1, WebFileNodeV1, WebFileTreePageV1, WebGitCommitV1, WebGitDiffPageV1, WebGitFileV1, WebGitLogPageV1, WebGitStatusV1, WebReviewSnapshotV1, WebReviewVerificationV1, WebTerminalCreateResultV1, WebTerminalExitV1, WebTerminalGapV1, WebTerminalMetadataV1, WebTerminalOutputFrameV1, WebTerminalStateV1, };
379
+ export type { WebFileContentPageV1, WebFileNodeV1, WebFileTreePageV1, WebGitCommitV1, WebGitDiffPageV1, WebGitFileV1, WebGitLogPageV1, WebGitStatusV1, WebReviewSnapshotV1, WebTerminalCreateResultV1, WebTerminalExitV1, WebTerminalGapV1, WebTerminalMetadataV1, WebTerminalOutputFrameV1, WebTerminalStateV1, };
373
380
  export interface WebBootstrapV1 {
374
381
  readonly apiVersion: 1;
375
382
  readonly productVersion: string;
@@ -523,6 +523,7 @@ function parseContextReferences(value) {
523
523
  const row = requireRecord(entry, `contextReferences[${index}]`);
524
524
  const kind = requireEnum(row.kind, `contextReferences[${index}].kind`, [
525
525
  'file',
526
+ 'file_range',
526
527
  'folder',
527
528
  'review',
528
529
  'session',
@@ -534,6 +535,22 @@ function parseContextReferences(value) {
534
535
  }
535
536
  seen.add(`${kind}:${id}`);
536
537
  const label = requireBoundedString(row.label, `contextReferences[${index}].label`, 200);
538
+ if (kind === 'file_range') {
539
+ assertOnlyKeys(row, ['kind', 'id', 'label', 'revision', 'startLine', 'endLine'], 'Context reference');
540
+ const startLine = requireLineNumber(row.startLine, `contextReferences[${index}].startLine`);
541
+ const endLine = requireLineNumber(row.endLine, `contextReferences[${index}].endLine`);
542
+ if (endLine < startLine) {
543
+ throw new WebProtocolError('file_range endLine must not precede startLine');
544
+ }
545
+ return Object.freeze({
546
+ kind,
547
+ id,
548
+ label,
549
+ revision: requireBoundedString(row.revision, `contextReferences[${index}].revision`, 256),
550
+ startLine,
551
+ endLine,
552
+ });
553
+ }
537
554
  if (kind === 'file' || kind === 'folder') {
538
555
  assertOnlyKeys(row, ['kind', 'id', 'label', 'revision'], 'Context reference');
539
556
  return Object.freeze({
@@ -573,6 +590,15 @@ function assertOnlyKeys(row, keys, subject = 'command') {
573
590
  if (unknown)
574
591
  throw new WebProtocolError(`Unknown ${subject} field: ${unknown}`);
575
592
  }
593
+ function requireLineNumber(value, name) {
594
+ if (typeof value !== 'number' ||
595
+ !Number.isSafeInteger(value) ||
596
+ value < 1 ||
597
+ value > 100000000) {
598
+ throw new WebProtocolError(`${name} must be an integer line number from 1 through 100000000`);
599
+ }
600
+ return value;
601
+ }
576
602
  function requireBoundedString(value, name, maxLength) {
577
603
  if (typeof value !== 'string' || !value.trim()) {
578
604
  throw new WebProtocolError(`${name} must be a non-empty string`);
@@ -1,19 +1,4 @@
1
- import type { VerifiedDurableToolReceiptRefV1 } from '../runtime/durable-tool-receipt-reader';
2
1
  import type { GitReadModelServiceV1, WebGitDiffPageV1, WebGitFileV1 } from './git-read-model-service';
3
- export interface WebReviewVerificationV1 {
4
- readonly callId: string;
5
- readonly sessionId: string;
6
- readonly threadId: string;
7
- readonly sequence: number;
8
- readonly toolName: string;
9
- readonly state: 'success' | 'error' | 'skipped';
10
- readonly terminal: VerifiedDurableToolReceiptRefV1['terminal'];
11
- readonly success: boolean;
12
- readonly outputBytes: number;
13
- readonly hasArtifact: boolean;
14
- readonly executionPolicyDigest: string;
15
- readonly receiptDigest: string;
16
- }
17
2
  export interface WebReviewSnapshotV1 {
18
3
  readonly revision: string;
19
4
  readonly repositoryRevision: string;
@@ -26,13 +11,11 @@ export interface WebReviewSnapshotV1 {
26
11
  readonly untrackedCount: number;
27
12
  readonly conflictCount: number;
28
13
  readonly truncated: boolean;
29
- readonly verification: readonly WebReviewVerificationV1[];
30
14
  }
31
- /** Review overview composed only from Git facts and doubly verified durable tool receipts. */
15
+ /** Review overview composed only from Git facts. */
32
16
  export declare class ReviewServiceV1 {
33
17
  private readonly git;
34
- private readonly listReceiptRefs;
35
- constructor(git: GitReadModelServiceV1, listReceiptRefs: () => Promise<readonly VerifiedDurableToolReceiptRefV1[]> | readonly VerifiedDurableToolReceiptRefV1[]);
18
+ constructor(git: GitReadModelServiceV1);
36
19
  snapshot(): Promise<WebReviewSnapshotV1>;
37
20
  diff(input: Parameters<GitReadModelServiceV1['diff']>[0]): Promise<WebGitDiffPageV1>;
38
21
  }
@@ -2,21 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ReviewServiceV1 = void 0;
4
4
  const crypto_1 = require("crypto");
5
- /** Review overview composed only from Git facts and doubly verified durable tool receipts. */
5
+ /** Review overview composed only from Git facts. */
6
6
  class ReviewServiceV1 {
7
- constructor(git, listReceiptRefs) {
7
+ constructor(git) {
8
8
  this.git = git;
9
- this.listReceiptRefs = listReceiptRefs;
10
9
  }
11
10
  async snapshot() {
12
11
  // v0.3.9 #237/#228 — collect every Git status page. A repository with more
13
12
  // than the single pageSize of changed files was silently truncated before;
14
13
  // the review overview must aggregate all pages (bounded by a guard that
15
14
  // keeps runaway repositories from looping forever).
16
- const [receiptRefs, statusPages] = await Promise.all([
17
- this.listReceiptRefs(),
18
- collectStatusPages((cursor) => this.git.status({ pageSize: 2000, ...(cursor ? { cursor } : {}) })),
19
- ]);
15
+ const statusPages = await collectStatusPages((cursor) => this.git.status({ pageSize: 2000, ...(cursor ? { cursor } : {}) }));
20
16
  const status = statusPages.at(-1) ?? (await this.git.status({ pageSize: 2000 }));
21
17
  const conflicted = [];
22
18
  const staged = [];
@@ -29,25 +25,8 @@ class ReviewServiceV1 {
29
25
  untracked.push(...page.untracked);
30
26
  }
31
27
  const changedFiles = uniqueFiles([...conflicted, ...staged, ...unstaged, ...untracked]);
32
- const verification = receiptRefs.slice(0, 100).map(receipt => Object.freeze({
33
- callId: receipt.callId,
34
- sessionId: receipt.sessionId,
35
- threadId: receipt.threadId,
36
- sequence: receipt.sequence,
37
- toolName: receipt.toolName,
38
- state: reviewState(receipt),
39
- terminal: receipt.terminal,
40
- success: receipt.success,
41
- outputBytes: receipt.outputBytes,
42
- hasArtifact: receipt.hasArtifact,
43
- executionPolicyDigest: receipt.executionPolicyDigest,
44
- receiptDigest: receipt.receiptDigest,
45
- }));
46
28
  const revision = (0, crypto_1.createHash)('sha256')
47
- .update(JSON.stringify({
48
- repositoryRevision: status.repositoryRevision,
49
- verification,
50
- }))
29
+ .update(JSON.stringify({ repositoryRevision: status.repositoryRevision }))
51
30
  .digest('hex');
52
31
  return Object.freeze({
53
32
  revision,
@@ -61,7 +40,6 @@ class ReviewServiceV1 {
61
40
  untrackedCount: untracked.length,
62
41
  conflictCount: conflicted.length,
63
42
  truncated: statusPages.at(-1)?.truncated ?? false,
64
- verification: Object.freeze(verification),
65
43
  });
66
44
  }
67
45
  diff(input) {
@@ -69,25 +47,12 @@ class ReviewServiceV1 {
69
47
  }
70
48
  }
71
49
  exports.ReviewServiceV1 = ReviewServiceV1;
72
- function reviewState(receipt) {
73
- if (receipt.success)
74
- return 'success';
75
- return receipt.terminal === 'interrupted' || receipt.terminal === 'indeterminate'
76
- ? 'skipped'
77
- : 'error';
78
- }
79
50
  function uniqueFiles(files) {
80
51
  const byId = new Map();
81
52
  for (const file of files)
82
53
  byId.set(file.fileId, file);
83
54
  return [...byId.values()].sort((left, right) => left.path.localeCompare(right.path));
84
55
  }
85
- /**
86
- * v0.3.9 #237/#228 — page through Git status until the snapshot is fully
87
- * collected. The bounded guard (64 pages x 2_000 = 128k files) exists to stop
88
- * runaway repositories from looping; a repository exceeding it stays marked
89
- * truncated so the UI can still communicate the limit.
90
- */
91
56
  async function collectStatusPages(fetchPage) {
92
57
  const pages = [];
93
58
  let page = await fetchPage(undefined);
@@ -387,6 +387,15 @@ async function handleRequest(context) {
387
387
  sendJson(response, 200, collectionPage(url, 'mcp', context.workbench.mcp(contextGuard), server => server.id));
388
388
  return;
389
389
  }
390
+ if (method === 'GET' && path === '/files/search') {
391
+ const contextGuard = requireContextGuardQuery(url);
392
+ const query = url.searchParams.get('q') ?? '';
393
+ const scopeValue = url.searchParams.get('scope');
394
+ const scope = scopeValue === 'content' ? 'content' : 'name';
395
+ const limit = boundedInteger(url.searchParams.get('limit'), 50, 1, 200);
396
+ sendJson(response, 200, context.workbench.searchFiles(contextGuard, { query, scope, limit }));
397
+ return;
398
+ }
390
399
  if (method === 'GET' && path === '/files') {
391
400
  const contextGuard = requireContextGuardQuery(url);
392
401
  sendJson(response, 200, context.workbench.listFiles(contextGuard, {
@@ -422,6 +431,73 @@ async function handleRequest(context) {
422
431
  }));
423
432
  return;
424
433
  }
434
+ const fileWriteMatch = path === '/files/write';
435
+ if (method === 'POST' && fileWriteMatch) {
436
+ assertMutation(request, context.nonce, context.origin, 'file_mutation_forbidden');
437
+ assertFileUserGesture(request);
438
+ const body = requireRecord(await readJson(request), 'File write request');
439
+ assertOnlyKeys(body, [
440
+ 'requestId',
441
+ 'fileId',
442
+ 'content',
443
+ 'expectedRevision',
444
+ 'workspaceId',
445
+ 'expectedContextRevision',
446
+ ]);
447
+ const requestId = requireUuid(body.requestId, 'requestId');
448
+ const contextGuard = requireContextGuardRecord(body);
449
+ const fileId = requireText(body.fileId, 'fileId', 256);
450
+ const content = requireText(body.content, 'content', 512 * 1024 + 8);
451
+ const expectedRevision = requireFileRevision(body.expectedRevision);
452
+ const result = await context.workbench.executeMutation(requestId, 'file.write', { fileId, expectedRevision, ...contextGuard }, () => context.workbench.writeFileContent(contextGuard, {
453
+ fileId,
454
+ content,
455
+ expectedRevision,
456
+ }));
457
+ sendJson(response, 200, result);
458
+ return;
459
+ }
460
+ if (method === 'POST' && (path === '/git/stage' || path === '/git/unstage')) {
461
+ assertMutation(request, context.nonce, context.origin, 'git_mutation_forbidden');
462
+ assertGitUserGesture(request);
463
+ const body = requireRecord(await readJson(request), 'Git stage request');
464
+ assertOnlyKeys(body, [
465
+ 'requestId',
466
+ 'expectedContextRevision',
467
+ 'workspaceId',
468
+ 'fileIds',
469
+ 'expectedRepositoryRevision',
470
+ ]);
471
+ const requestId = requireUuid(body.requestId, 'requestId');
472
+ const contextGuard = requireContextGuardRecord(body);
473
+ const fileIds = requireStringArray(body.fileIds, 'fileIds', 200, 256);
474
+ const expectedRepositoryRevision = requireGitRevision(body.expectedRepositoryRevision);
475
+ const action = path === '/git/stage' ? 'stage' : 'unstage';
476
+ const result = await context.workbench.executeMutation(requestId, 'git.mutation', { action, fileIds, expectedRepositoryRevision, ...contextGuard }, async () => action === 'stage'
477
+ ? context.workbench.gitStage(contextGuard, { fileIds, expectedRepositoryRevision })
478
+ : context.workbench.gitUnstage(contextGuard, { fileIds, expectedRepositoryRevision }));
479
+ sendJson(response, 200, result);
480
+ return;
481
+ }
482
+ if (method === 'POST' && path === '/git/commit') {
483
+ assertMutation(request, context.nonce, context.origin, 'git_mutation_forbidden');
484
+ assertGitUserGesture(request);
485
+ const body = requireRecord(await readJson(request), 'Git commit request');
486
+ assertOnlyKeys(body, [
487
+ 'requestId',
488
+ 'expectedContextRevision',
489
+ 'workspaceId',
490
+ 'message',
491
+ 'expectedRepositoryRevision',
492
+ ]);
493
+ const requestId = requireUuid(body.requestId, 'requestId');
494
+ const contextGuard = requireContextGuardRecord(body);
495
+ const message = requireText(body.message, 'message', 2000);
496
+ const expectedRepositoryRevision = requireGitRevision(body.expectedRepositoryRevision);
497
+ const result = await context.workbench.executeMutation(requestId, 'git.commit', { message, expectedRepositoryRevision, ...contextGuard }, async () => context.workbench.gitCommit(contextGuard, { message, expectedRepositoryRevision }));
498
+ sendJson(response, 200, result);
499
+ return;
500
+ }
425
501
  if (method === 'GET' && path === '/git/log') {
426
502
  const contextGuard = requireContextGuardQuery(url);
427
503
  sendJson(response, 200, await context.workbench.gitLog(contextGuard, {
@@ -569,6 +645,41 @@ function assertMutation(request, nonce, origin, forbiddenCode = 'request_forbidd
569
645
  throw new HttpProblem(415, 'Mutations require application/json.');
570
646
  }
571
647
  }
648
+ function requireStringArray(value, name, maxLength, maxItemBytes) {
649
+ if (!Array.isArray(value) || value.length === 0 || value.length > maxLength) {
650
+ throw new HttpProblem(400, `${name} must list 1 through ${maxLength} items.`);
651
+ }
652
+ return value.map(item => {
653
+ if (typeof item !== 'string' || item.length === 0 || item.length > maxItemBytes) {
654
+ throw new HttpProblem(400, `${name} contains an invalid item.`);
655
+ }
656
+ return item;
657
+ });
658
+ }
659
+ function requireGitRevision(value) {
660
+ const text = requireText(value, 'expectedRepositoryRevision', 64);
661
+ if (!/^[0-9a-f]{40,64}$/u.test(text)) {
662
+ throw new HttpProblem(400, 'expectedRepositoryRevision must be a git revision.');
663
+ }
664
+ return text;
665
+ }
666
+ function assertFileUserGesture(request) {
667
+ if (request.headers[protocol_1.WEB_USER_GESTURE_HEADER] !== 'file-mutation-v1') {
668
+ throw new HttpProblem(403, 'File mutations require an explicit browser user gesture.', 'file_user_gesture_required');
669
+ }
670
+ }
671
+ function requireFileRevision(value) {
672
+ const text = requireText(value, 'expectedRevision', 64);
673
+ if (!/^[0-9a-f]{64}$/u.test(text)) {
674
+ throw new HttpProblem(400, 'expectedRevision must be a file content revision.');
675
+ }
676
+ return text;
677
+ }
678
+ function assertGitUserGesture(request) {
679
+ if (request.headers[protocol_1.WEB_USER_GESTURE_HEADER] !== 'git-mutation-v1') {
680
+ throw new HttpProblem(403, 'Git mutations require an explicit browser user gesture.', 'git_user_gesture_required');
681
+ }
682
+ }
572
683
  function assertTerminalUserGesture(request) {
573
684
  if (request.headers[protocol_1.WEB_USER_GESTURE_HEADER] !== 'terminal-create-v1') {
574
685
  throw new HttpProblem(403, 'Terminal creation requires an explicit browser user gesture.', 'terminal_user_gesture_required');
@@ -43,6 +43,7 @@ export declare class WebWorkbenchController {
43
43
  private readonly workspaceMutationOwners;
44
44
  private readonly pendingWorkspaceMutationStates;
45
45
  private fileService;
46
+ private fileWriteService;
46
47
  private gitService;
47
48
  private reviewService;
48
49
  private contextRevisionValue;
@@ -100,7 +101,53 @@ export declare class WebWorkbenchController {
100
101
  readToolDetail(callId: string, offsetBytes: number, limitBytes: number, context?: WebContextGuardV1): Promise<WebToolDetailPageV1>;
101
102
  listFiles(context: WebContextGuardV1, input: Parameters<FileReadServiceV1['list']>[0]): import("./file-read-service").WebFileTreePageV1;
102
103
  readFileContent(context: WebContextGuardV1, input: Parameters<FileReadServiceV1['readContent']>[0]): import("./file-read-service").WebFileContentPageV1;
104
+ /** v0.3.14 — guarded CAS write for an existing in-root text file. */
105
+ writeFileContent(context: WebContextGuardV1, input: {
106
+ readonly fileId: string;
107
+ readonly content: string;
108
+ readonly expectedRevision: string;
109
+ }): {
110
+ readonly fileId: string;
111
+ readonly revision: string;
112
+ readonly sizeBytes: number;
113
+ };
103
114
  gitStatus(context: WebContextGuardV1, input: Parameters<GitReadModelServiceV1['status']>[0]): Promise<import("./git-read-model-service").WebGitStatusV1>;
115
+ searchFiles(context: WebContextGuardV1, input: {
116
+ readonly query: string;
117
+ readonly scope: 'name' | 'content';
118
+ readonly limit?: number;
119
+ }): Readonly<{
120
+ revision: string;
121
+ items: readonly Readonly<{
122
+ id: string;
123
+ name: string;
124
+ path: string;
125
+ }>[];
126
+ truncated: boolean;
127
+ }>;
128
+ /** v0.3.12 S3 — guarded stage/unstage/commit with repository revision CAS. */
129
+ gitStage(context: WebContextGuardV1, input: {
130
+ readonly fileIds: readonly string[];
131
+ readonly expectedRepositoryRevision: string;
132
+ }): Promise<{
133
+ readonly repositoryRevision: string;
134
+ }>;
135
+ gitUnstage(context: WebContextGuardV1, input: {
136
+ readonly fileIds: readonly string[];
137
+ readonly expectedRepositoryRevision: string;
138
+ }): Promise<{
139
+ readonly repositoryRevision: string;
140
+ }>;
141
+ gitCommit(context: WebContextGuardV1, input: {
142
+ readonly message: string;
143
+ readonly expectedRepositoryRevision: string;
144
+ }): Promise<{
145
+ readonly repositoryRevision: string;
146
+ readonly commitSha: string;
147
+ }>;
148
+ private resolveSafeGitPaths;
149
+ private assertGitRepositoryRevision;
150
+ private fileContextRevision;
104
151
  gitLog(context: WebContextGuardV1, input: Parameters<GitReadModelServiceV1['log']>[0]): Promise<import("./git-read-model-service").WebGitLogPageV1>;
105
152
  gitDiff(context: WebContextGuardV1, input: Parameters<GitReadModelServiceV1['diff']>[0]): Promise<import("./git-read-model-service").WebGitDiffPageV1>;
106
153
  review(context: WebContextGuardV1): Promise<import("./review-service").WebReviewSnapshotV1>;