@leg3ndy/otto-bridge 0.9.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
@@ -8,6 +8,9 @@ import { loadManagedBridgeExtensionState, saveManagedBridgeExtensionState, } fro
8
8
  import { postDeviceJson, uploadDeviceJobArtifact } from "../http.js";
9
9
  import { WHATSAPP_WEB_URL, WhatsAppBackgroundBrowser, } from "../whatsapp_background.js";
10
10
  import { verifyExpectedWhatsAppMessage } from "../whatsapp_verification.js";
11
+ import { parseJobRuntimeManifest, runtimeExpectedArtifactIdForActionIndex, runtimeStepIdForActionIndex, } from "../runtime_contract.js";
12
+ import { applyStructuredUpdateToText, parseStructuredPatch, } from "../agentic_runtime/patch/structured_patch.js";
13
+ import { assertActionAllowedByWorkspacePolicy, assertCwdInsideWorkspace, assertPathInsideWorkspace, buildWorkspaceMemory, expandUserPathLike, resolveWorkspaceContext, } from "../agentic_runtime/workspace/manager.js";
11
14
  const KNOWN_APPS = [
12
15
  { canonical: "Safari", patterns: [/\bsafari\b/i] },
13
16
  { canonical: "Google Chrome", patterns: [/\bgoogle chrome\b/i, /\bchrome\b/i] },
@@ -47,6 +50,16 @@ const FILE_SEARCH_SKIP_DIRS = new Set([
47
50
  "Library",
48
51
  ".Trash",
49
52
  ]);
53
+ const RUN_TEST_PROFILES = new Set([
54
+ "pytest",
55
+ "npm_test",
56
+ "pnpm_test",
57
+ "yarn_test",
58
+ "bun_test",
59
+ "lint",
60
+ "build",
61
+ ]);
62
+ const PACKAGE_MANAGER_PRIORITY = ["pnpm", "yarn", "bun", "npm"];
50
63
  const GENERIC_VISUAL_STOP_WORDS = new Set([
51
64
  "o",
52
65
  "a",
@@ -675,20 +688,7 @@ function mimeTypeFromPath(filePath) {
675
688
  return "application/octet-stream";
676
689
  }
677
690
  function expandUserPath(value) {
678
- const trimmed = value.trim();
679
- if (!trimmed) {
680
- return os.homedir();
681
- }
682
- if (trimmed === "~") {
683
- return os.homedir();
684
- }
685
- if (trimmed.startsWith("~/")) {
686
- return path.join(os.homedir(), trimmed.slice(2));
687
- }
688
- if (path.isAbsolute(trimmed)) {
689
- return trimmed;
690
- }
691
- return path.resolve(process.cwd(), trimmed);
691
+ return expandUserPathLike(value, process.cwd());
692
692
  }
693
693
  function clipText(value, maxLength) {
694
694
  if (value.length <= maxLength) {
@@ -1044,6 +1044,245 @@ function parseStructuredActions(job) {
1044
1044
  }
1045
1045
  continue;
1046
1046
  }
1047
+ if (type === "write_json_file" || type === "write_json" || type === "save_json_file" || type === "save_json") {
1048
+ const targetPath = asString(action.path)
1049
+ || asString(action.destination)
1050
+ || asString(action.file_path)
1051
+ || (asString(action.filename) ? "~/Desktop" : "");
1052
+ const data = ("data" in action ? action.data : ("json" in action ? action.json : ("content" in action ? action.content : undefined)));
1053
+ if (targetPath && data !== undefined) {
1054
+ actions.push({
1055
+ type: "write_json_file",
1056
+ path: targetPath,
1057
+ data,
1058
+ filename: asString(action.filename) || asString(action.file_name) || asString(action.name) || undefined,
1059
+ pretty: action.pretty !== false,
1060
+ });
1061
+ }
1062
+ continue;
1063
+ }
1064
+ if (type === "apply_patch" || type === "patch_apply" || type === "edit_patch") {
1065
+ const patch = asString(action.patch) || asString(action.content) || asString(action.diff);
1066
+ const cwd = asString(action.cwd) || asString(action.path);
1067
+ if (patch && cwd) {
1068
+ const targetFiles = Array.isArray(action.target_files)
1069
+ ? action.target_files
1070
+ .map((item) => asString(item))
1071
+ .filter((item) => Boolean(item))
1072
+ : [];
1073
+ actions.push({
1074
+ type: "apply_patch",
1075
+ cwd,
1076
+ patch,
1077
+ target_files: targetFiles.length > 0 ? targetFiles : undefined,
1078
+ });
1079
+ }
1080
+ continue;
1081
+ }
1082
+ if (type === "mkdir" || type === "make_directory" || type === "create_directory") {
1083
+ const directoryPath = asString(action.path) || asString(action.destination) || asString(action.directory_path);
1084
+ if (directoryPath) {
1085
+ actions.push({
1086
+ type: "mkdir",
1087
+ path: directoryPath,
1088
+ create_parents: action.create_parents !== false,
1089
+ });
1090
+ }
1091
+ continue;
1092
+ }
1093
+ if (type === "move_file" || type === "move_path" || type === "rename_file") {
1094
+ const sourcePath = asString(action.source_path) || asString(action.source) || asString(action.path);
1095
+ const destinationPath = asString(action.destination_path) || asString(action.destination) || asString(action.target_path) || asString(action.target);
1096
+ if (sourcePath && destinationPath) {
1097
+ actions.push({
1098
+ type: "move_file",
1099
+ source_path: sourcePath,
1100
+ destination_path: destinationPath,
1101
+ overwrite: action.overwrite === true,
1102
+ });
1103
+ }
1104
+ continue;
1105
+ }
1106
+ if (type === "git_clone" || type === "clone_repo" || type === "repo_clone") {
1107
+ const repository = asString(action.repository) || asString(action.repo) || asString(action.url) || asString(action.remote);
1108
+ const destinationPath = asString(action.destination_path) || asString(action.destination) || asString(action.path) || asString(action.target_path);
1109
+ const depth = typeof action.depth === "number" ? Math.max(1, Math.min(1_000, Math.round(action.depth))) : undefined;
1110
+ if (repository && destinationPath) {
1111
+ actions.push({
1112
+ type: "git_clone",
1113
+ repository,
1114
+ destination_path: destinationPath,
1115
+ branch: asString(action.branch) || asString(action.ref) || undefined,
1116
+ depth,
1117
+ });
1118
+ }
1119
+ continue;
1120
+ }
1121
+ if (type === "git_fetch" || type === "fetch_repo" || type === "repo_fetch") {
1122
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1123
+ if (cwd) {
1124
+ actions.push({
1125
+ type: "git_fetch",
1126
+ cwd,
1127
+ remote: asString(action.remote) || undefined,
1128
+ prune: action.prune === true,
1129
+ tags: action.tags === true,
1130
+ });
1131
+ }
1132
+ continue;
1133
+ }
1134
+ if (type === "git_checkout" || type === "checkout_branch" || type === "switch_branch") {
1135
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1136
+ const target = asString(action.target) || asString(action.branch) || asString(action.ref) || asString(action.target_ref);
1137
+ if (cwd && target) {
1138
+ actions.push({
1139
+ type: "git_checkout",
1140
+ cwd,
1141
+ target,
1142
+ start_point: asString(action.start_point) || asString(action.startPoint) || asString(action.from_ref) || undefined,
1143
+ create_branch: action.create_branch === true || action.new_branch === true,
1144
+ detach: action.detach === true,
1145
+ });
1146
+ }
1147
+ continue;
1148
+ }
1149
+ if (type === "git_rebase" || type === "rebase_branch") {
1150
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1151
+ const target = asString(action.target) || asString(action.upstream) || asString(action.branch) || asString(action.ref);
1152
+ if (cwd && target) {
1153
+ actions.push({
1154
+ type: "git_rebase",
1155
+ cwd,
1156
+ target,
1157
+ autostash: action.autostash === true,
1158
+ });
1159
+ }
1160
+ continue;
1161
+ }
1162
+ if (type === "git_merge" || type === "merge_branch") {
1163
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1164
+ const target = asString(action.target) || asString(action.branch) || asString(action.ref) || asString(action.source_branch);
1165
+ if (cwd && target) {
1166
+ actions.push({
1167
+ type: "git_merge",
1168
+ cwd,
1169
+ target,
1170
+ ff_only: action.ff_only === true,
1171
+ no_ff: action.no_ff === true,
1172
+ message: asString(action.message) || asString(action.merge_message) || undefined,
1173
+ });
1174
+ }
1175
+ continue;
1176
+ }
1177
+ if (type === "git_tag" || type === "create_tag") {
1178
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1179
+ const name = asString(action.name) || asString(action.tag) || asString(action.tag_name);
1180
+ if (cwd && name) {
1181
+ actions.push({
1182
+ type: "git_tag",
1183
+ cwd,
1184
+ name,
1185
+ target: asString(action.target) || asString(action.ref) || undefined,
1186
+ annotated: action.annotated === true,
1187
+ message: asString(action.message) || asString(action.annotation) || undefined,
1188
+ });
1189
+ }
1190
+ continue;
1191
+ }
1192
+ if (type === "git_add" || type === "git_stage" || type === "stage_files" || type === "stage_changes") {
1193
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1194
+ if (cwd) {
1195
+ const rawPaths = Array.isArray(action.paths)
1196
+ ? action.paths
1197
+ .map((item) => asString(item))
1198
+ .filter((item) => Boolean(item))
1199
+ : [];
1200
+ const fallbackTargetPath = asString(action.target_path) || asString(action.file_path) || asString(action.pathspec);
1201
+ actions.push({
1202
+ type: "git_add",
1203
+ cwd,
1204
+ all: action.all === true || action.stage_all === true || (rawPaths.length === 0 && !fallbackTargetPath),
1205
+ paths: rawPaths.length > 0 ? rawPaths : (fallbackTargetPath ? [fallbackTargetPath] : undefined),
1206
+ });
1207
+ }
1208
+ continue;
1209
+ }
1210
+ if (type === "git_commit" || type === "commit_changes" || type === "git_commit_changes") {
1211
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1212
+ const message = asString(action.message) || asString(action.commit_message) || asString(action.summary);
1213
+ if (cwd && message) {
1214
+ actions.push({
1215
+ type: "git_commit",
1216
+ cwd,
1217
+ message,
1218
+ allow_empty: action.allow_empty === true,
1219
+ });
1220
+ }
1221
+ continue;
1222
+ }
1223
+ if (type === "git_push" || type === "push_changes" || type === "git_push_branch") {
1224
+ const cwd = asString(action.cwd) || asString(action.repo_path) || asString(action.repository_path) || asString(action.workspace_path) || asString(action.path);
1225
+ if (cwd) {
1226
+ actions.push({
1227
+ type: "git_push",
1228
+ cwd,
1229
+ remote: asString(action.remote) || undefined,
1230
+ branch: asString(action.branch) || asString(action.ref) || undefined,
1231
+ set_upstream: action.set_upstream === true,
1232
+ });
1233
+ }
1234
+ continue;
1235
+ }
1236
+ if (type === "git_status" || type === "repo_status") {
1237
+ const cwd = asString(action.cwd) || asString(action.path);
1238
+ if (cwd) {
1239
+ actions.push({
1240
+ type: "git_status",
1241
+ cwd,
1242
+ include_untracked: action.include_untracked !== false,
1243
+ });
1244
+ }
1245
+ continue;
1246
+ }
1247
+ if (type === "git_diff" || type === "repo_diff") {
1248
+ const cwd = asString(action.cwd) || asString(action.path);
1249
+ if (cwd) {
1250
+ const rawPaths = Array.isArray(action.paths)
1251
+ ? action.paths
1252
+ .map((item) => asString(item))
1253
+ .filter((item) => Boolean(item))
1254
+ : [];
1255
+ const fallbackTargetPath = asString(action.target_path);
1256
+ actions.push({
1257
+ type: "git_diff",
1258
+ cwd,
1259
+ staged: action.staged === true || action.cached === true,
1260
+ base_ref: asString(action.base_ref) || asString(action.base) || undefined,
1261
+ paths: rawPaths.length > 0 ? rawPaths : (fallbackTargetPath ? [fallbackTargetPath] : undefined),
1262
+ max_chars: typeof action.max_chars === "number" ? Math.max(1_000, Math.min(20_000, action.max_chars)) : undefined,
1263
+ });
1264
+ }
1265
+ continue;
1266
+ }
1267
+ if (type === "run_tests" || type === "tests" || type === "test_command") {
1268
+ const command = asString(action.command) || asString(action.cmd);
1269
+ const profileCandidate = asString(action.profile) || asString(action.preset) || "";
1270
+ const normalizedProfile = profileCandidate.toLowerCase().replace(/[-\s]+/g, "_");
1271
+ const profile = RUN_TEST_PROFILES.has(normalizedProfile) ? normalizedProfile : undefined;
1272
+ const cwd = asString(action.cwd) || asString(action.path);
1273
+ if ((command || profile) && cwd) {
1274
+ actions.push({
1275
+ type: "run_tests",
1276
+ command: command || undefined,
1277
+ profile,
1278
+ cwd,
1279
+ timeout_seconds: typeof action.timeout_seconds === "number"
1280
+ ? Math.max(5, Math.min(1_800, Math.round(action.timeout_seconds)))
1281
+ : undefined,
1282
+ });
1283
+ }
1284
+ continue;
1285
+ }
1047
1286
  if (type === "list_files" || type === "ls") {
1048
1287
  const filePath = asString(action.path) || "~";
1049
1288
  const limit = typeof action.limit === "number" ? Math.max(1, Math.min(5_000, action.limit)) : undefined;
@@ -1080,6 +1319,13 @@ function parseStructuredActions(job) {
1080
1319
  }
1081
1320
  continue;
1082
1321
  }
1322
+ if (type === "delete_file" || type === "remove_file") {
1323
+ const filePath = asString(action.path) || asString(action.target_path);
1324
+ if (filePath) {
1325
+ actions.push({ type: "delete_file", path: filePath });
1326
+ }
1327
+ continue;
1328
+ }
1083
1329
  if (type === "set_volume" || type === "volume") {
1084
1330
  const rawLevel = Number(action.level);
1085
1331
  if (Number.isFinite(rawLevel)) {
@@ -1249,6 +1495,46 @@ function extractActions(job) {
1249
1495
  }
1250
1496
  return deriveActionsFromText(job);
1251
1497
  }
1498
+ function bindStepReporter(reporter, stepId) {
1499
+ const normalizedStepId = String(stepId || "").trim() || undefined;
1500
+ const withStepId = (options) => ({
1501
+ ...(options || {}),
1502
+ stepId: String(options?.stepId || normalizedStepId || "").trim() || undefined,
1503
+ });
1504
+ return {
1505
+ accepted: (options) => reporter.accepted(withStepId(options)),
1506
+ progress: (progressPercent, progressMessage, options) => reporter.progress(progressPercent, progressMessage, withStepId(options)),
1507
+ confirmRequired: (progressMessage, confirmationContext, options) => reporter.confirmRequired(progressMessage, confirmationContext, withStepId(options)),
1508
+ completed: (result, options) => reporter.completed(result, withStepId(options)),
1509
+ failed: (errorMessage, result, options) => reporter.failed(errorMessage, result, withStepId(options)),
1510
+ };
1511
+ }
1512
+ function appendInlineRuntimeArtifact(artifacts, runtimeManifest, actionIndex, kind, payload, stepId) {
1513
+ const artifactId = runtimeExpectedArtifactIdForActionIndex(runtimeManifest, actionIndex, kind);
1514
+ if (!artifactId) {
1515
+ return;
1516
+ }
1517
+ const metadata = asRecord(payload.metadata);
1518
+ const artifact = {
1519
+ ...payload,
1520
+ id: String(payload.id || artifactId).trim() || artifactId,
1521
+ artifact_id: artifactId,
1522
+ kind,
1523
+ metadata: {
1524
+ ...metadata,
1525
+ expected_artifact_id: artifactId,
1526
+ inline: true,
1527
+ action_index: actionIndex,
1528
+ step_id: String(stepId || "").trim() || undefined,
1529
+ },
1530
+ };
1531
+ const existingIndex = artifacts.findIndex((item) => (String(item.artifact_id || item.id || "").trim() === artifactId));
1532
+ if (existingIndex >= 0) {
1533
+ artifacts[existingIndex] = artifact;
1534
+ return;
1535
+ }
1536
+ artifacts.push(artifact);
1537
+ }
1252
1538
  export class NativeMacOSJobExecutor {
1253
1539
  bridgeConfig;
1254
1540
  cancelledJobs = new Set();
@@ -1273,6 +1559,14 @@ export class NativeMacOSJobExecutor {
1273
1559
  throw new Error("The native-macos executor only runs on macOS");
1274
1560
  }
1275
1561
  const actions = extractActions(job);
1562
+ const runtimeManifest = parseJobRuntimeManifest(job);
1563
+ const stepWorkerIdById = new Map(runtimeManifest.steps
1564
+ .filter((step) => step.step_id)
1565
+ .map((step) => [step.step_id, step.worker_id || undefined]));
1566
+ const workspaceContext = await resolveWorkspaceContext({
1567
+ workspaceContext: runtimeManifest.workspaceContext,
1568
+ actions,
1569
+ });
1276
1570
  if (actions.length === 0) {
1277
1571
  throw new Error("Otto Bridge native-macos could not derive a supported local action from this request");
1278
1572
  }
@@ -1285,39 +1579,307 @@ export class NativeMacOSJobExecutor {
1285
1579
  const decision = await reporter.confirmRequired(confirmation.message, {
1286
1580
  actions,
1287
1581
  executor: "native-macos",
1582
+ }, {
1583
+ stepId: runtimeManifest.approvalStepId,
1288
1584
  });
1289
1585
  if (decision.action !== "approve") {
1290
1586
  throw new JobCancelledError(job.job_id);
1291
1587
  }
1292
1588
  }
1293
- try {
1294
- const completionNotes = [];
1295
- const artifacts = [];
1296
- const resultPayload = {
1297
- executor: "native-macos",
1589
+ const completionNotes = [];
1590
+ const artifacts = [];
1591
+ const hookTrace = [];
1592
+ const resultPayload = {
1593
+ executor: "native-macos",
1594
+ actions,
1595
+ artifacts,
1596
+ action_summaries: completionNotes,
1597
+ runtime_hook_trace: hookTrace,
1598
+ };
1599
+ if (runtimeManifest.commandPacks.length > 0) {
1600
+ resultPayload.command_packs = runtimeManifest.commandPacks;
1601
+ artifacts.push({
1602
+ id: `command_packs.${job.job_id}`,
1603
+ kind: "command_packs",
1604
+ summary: `Command packs declarados para este job: ${runtimeManifest.commandPacks.map((item) => item.pack_id).join(", ")}.`,
1605
+ metadata: {
1606
+ pack_count: runtimeManifest.commandPacks.length,
1607
+ selected_pack_ids: runtimeManifest.commandPacks.filter((item) => item.selected === true).map((item) => item.pack_id),
1608
+ },
1609
+ });
1610
+ }
1611
+ if (runtimeManifest.workerProfiles.length > 0) {
1612
+ resultPayload.worker_profiles = runtimeManifest.workerProfiles;
1613
+ artifacts.push({
1614
+ id: `worker_profiles.${job.job_id}`,
1615
+ kind: "worker_profiles",
1616
+ summary: `Perfis declarados para este job: ${runtimeManifest.workerProfiles.map((item) => item.worker_id).join(", ")}.`,
1617
+ metadata: {
1618
+ worker_count: runtimeManifest.workerProfiles.length,
1619
+ worker_ids: runtimeManifest.workerProfiles.map((item) => item.worker_id),
1620
+ },
1621
+ });
1622
+ }
1623
+ if (runtimeManifest.workerExecution.length > 0) {
1624
+ resultPayload.worker_execution = runtimeManifest.workerExecution;
1625
+ artifacts.push({
1626
+ id: `worker_execution.${job.job_id}`,
1627
+ kind: "worker_execution",
1628
+ summary: `Policies declaradas para ${runtimeManifest.workerExecution.length} workers deste job.`,
1629
+ metadata: {
1630
+ worker_count: runtimeManifest.workerExecution.length,
1631
+ worker_ids: runtimeManifest.workerExecution.map((item) => item.worker_id),
1632
+ },
1633
+ });
1634
+ }
1635
+ if (runtimeManifest.instructionUpdate) {
1636
+ resultPayload.instruction_update = runtimeManifest.instructionUpdate;
1637
+ artifacts.push({
1638
+ id: `instruction_update.${job.job_id}`,
1639
+ kind: "instruction_update",
1640
+ summary: runtimeManifest.instructionUpdate.reason || "Instruction updater declarado para este job.",
1641
+ metadata: {
1642
+ updater_id: runtimeManifest.instructionUpdate.updater_id,
1643
+ selected: runtimeManifest.instructionUpdate.selected === true,
1644
+ target_count: runtimeManifest.instructionUpdate.target_paths.length,
1645
+ },
1646
+ });
1647
+ }
1648
+ if (runtimeManifest.validationLadder) {
1649
+ resultPayload.validation_ladder = runtimeManifest.validationLadder;
1650
+ artifacts.push({
1651
+ id: `validation_ladder.${job.job_id}`,
1652
+ kind: "validation_ladder",
1653
+ summary: runtimeManifest.validationLadder.summary || "Validation ladder declarada para este job.",
1654
+ metadata: {
1655
+ ladder_id: runtimeManifest.validationLadder.ladder_id,
1656
+ stage_count: runtimeManifest.validationLadder.stages.length,
1657
+ selected_command_pack_id: runtimeManifest.validationLadder.selected_command_pack_id || undefined,
1658
+ },
1659
+ });
1660
+ }
1661
+ if (runtimeManifest.hookBus) {
1662
+ resultPayload.hook_bus = runtimeManifest.hookBus;
1663
+ artifacts.push({
1664
+ id: `hook_bus.${job.job_id}`,
1665
+ kind: "hook_bus",
1666
+ summary: runtimeManifest.hookBus.summary || "Hook bus declarado para este job.",
1667
+ metadata: {
1668
+ event_count: runtimeManifest.hookBus.events.length,
1669
+ sink_count: runtimeManifest.hookBus.sinks.length,
1670
+ },
1671
+ });
1672
+ }
1673
+ if (workspaceContext) {
1674
+ resultPayload.workspace_context = {
1675
+ workspace_id: workspaceContext.workspaceId,
1676
+ repo_root: workspaceContext.repoRoot || undefined,
1677
+ default_cwd: workspaceContext.defaultCwd,
1678
+ summary: workspaceContext.summary,
1679
+ roots: workspaceContext.roots,
1680
+ targets: workspaceContext.targets,
1681
+ workspace_policy: workspaceContext.workspacePolicy || undefined,
1682
+ };
1683
+ artifacts.push({
1684
+ id: `workspace_context.${workspaceContext.workspaceId}`,
1685
+ kind: "workspace_context",
1686
+ summary: workspaceContext.summary,
1687
+ metadata: {
1688
+ workspace_id: workspaceContext.workspaceId,
1689
+ repo_root: workspaceContext.repoRoot || undefined,
1690
+ root_count: workspaceContext.roots.length,
1691
+ target_count: workspaceContext.targets.length,
1692
+ policy_profile_id: workspaceContext.workspacePolicy?.profile_id || undefined,
1693
+ },
1694
+ });
1695
+ if (workspaceContext.instructionBundle) {
1696
+ resultPayload.instruction_bundle = workspaceContext.instructionBundle;
1697
+ artifacts.push({
1698
+ id: `instruction_bundle.${workspaceContext.workspaceId}`,
1699
+ kind: "instruction_bundle",
1700
+ summary: workspaceContext.instructionBundle.summary,
1701
+ metadata: {
1702
+ workspace_id: workspaceContext.workspaceId,
1703
+ source_count: workspaceContext.instructionBundle.source_count,
1704
+ digest: workspaceContext.instructionBundle.digest,
1705
+ },
1706
+ });
1707
+ }
1708
+ if (workspaceContext.repoManifest) {
1709
+ resultPayload.repo_manifest = workspaceContext.repoManifest;
1710
+ artifacts.push({
1711
+ id: `repo_manifest.${workspaceContext.workspaceId}`,
1712
+ kind: "repo_manifest",
1713
+ summary: workspaceContext.repoManifest.summary,
1714
+ metadata: {
1715
+ workspace_id: workspaceContext.workspaceId,
1716
+ scoped_root_path: workspaceContext.repoManifest.scoped_root_path,
1717
+ detected_repo_root: workspaceContext.repoManifest.detected_repo_root || undefined,
1718
+ repo_root_within_workspace: workspaceContext.repoManifest.repo_root_within_workspace,
1719
+ repo_name: workspaceContext.repoManifest.repo_name,
1720
+ vcs: workspaceContext.repoManifest.vcs,
1721
+ manifest_file_count: workspaceContext.repoManifest.manifest_files.length,
1722
+ lockfile_count: workspaceContext.repoManifest.lockfiles.length,
1723
+ package_managers: workspaceContext.repoManifest.package_managers,
1724
+ },
1725
+ });
1726
+ }
1727
+ if (workspaceContext.workspaceIndex) {
1728
+ resultPayload.workspace_index = workspaceContext.workspaceIndex;
1729
+ artifacts.push({
1730
+ id: `workspace_index.${workspaceContext.workspaceId}`,
1731
+ kind: "workspace_index",
1732
+ summary: workspaceContext.workspaceIndex.summary,
1733
+ metadata: {
1734
+ workspace_id: workspaceContext.workspaceId,
1735
+ base_path: workspaceContext.workspaceIndex.base_path,
1736
+ scanned_directory_count: workspaceContext.workspaceIndex.scanned_directory_count,
1737
+ scanned_file_count: workspaceContext.workspaceIndex.scanned_file_count,
1738
+ truncated: workspaceContext.workspaceIndex.truncated,
1739
+ key_file_count: workspaceContext.workspaceIndex.key_files.length,
1740
+ },
1741
+ });
1742
+ }
1743
+ if (workspaceContext.workspacePolicy) {
1744
+ resultPayload.workspace_policy = workspaceContext.workspacePolicy;
1745
+ artifacts.push({
1746
+ id: `workspace_policy.${workspaceContext.workspaceId}`,
1747
+ kind: "workspace_policy",
1748
+ summary: workspaceContext.workspacePolicy.summary,
1749
+ metadata: {
1750
+ workspace_id: workspaceContext.workspaceId,
1751
+ profile_id: workspaceContext.workspacePolicy.profile_id,
1752
+ allow_shell: workspaceContext.workspacePolicy.allow_shell,
1753
+ allow_code_write: workspaceContext.workspacePolicy.allow_code_write,
1754
+ allow_destructive: workspaceContext.workspacePolicy.allow_destructive,
1755
+ allow_release: workspaceContext.workspacePolicy.allow_release,
1756
+ blocked_action_count: workspaceContext.workspacePolicy.blocked_action_types.length,
1757
+ },
1758
+ });
1759
+ }
1760
+ }
1761
+ const attachWorkspaceMemory = (jobStatus) => {
1762
+ if (!workspaceContext) {
1763
+ return;
1764
+ }
1765
+ const workspaceMemory = buildWorkspaceMemory({
1766
+ workspaceId: workspaceContext.workspaceId,
1767
+ workspace: workspaceContext,
1768
+ priorMemory: workspaceContext.workspaceMemory,
1298
1769
  actions,
1299
1770
  artifacts,
1300
- action_summaries: completionNotes,
1301
- };
1771
+ jobId: job.job_id,
1772
+ jobStatus,
1773
+ });
1774
+ resultPayload.workspace_memory = workspaceMemory;
1775
+ artifacts.push({
1776
+ id: `workspace_memory.${workspaceContext.workspaceId}`,
1777
+ kind: "workspace_memory",
1778
+ summary: workspaceMemory.summary,
1779
+ metadata: {
1780
+ workspace_id: workspaceContext.workspaceId,
1781
+ job_count: workspaceMemory.job_count,
1782
+ latest_job_id: workspaceMemory.latest_job_id || undefined,
1783
+ latest_job_status: workspaceMemory.latest_job_status || undefined,
1784
+ recent_action_count: workspaceMemory.recent_action_types.length,
1785
+ recent_target_count: workspaceMemory.recent_target_paths.length,
1786
+ recent_artifact_kind_count: workspaceMemory.recent_artifact_kinds.length,
1787
+ },
1788
+ });
1789
+ };
1790
+ let currentActionStepId;
1791
+ const appendHookEvent = (eventType, options) => {
1792
+ hookTrace.push({
1793
+ hook_id: `hook.${String(hookTrace.length + 1).padStart(3, "0")}`,
1794
+ event_type: eventType,
1795
+ recorded_at: new Date().toISOString(),
1796
+ source: "bridge_runtime",
1797
+ graph_id: runtimeManifest.graphId || undefined,
1798
+ step_id: options?.stepId || undefined,
1799
+ worker_id: (options?.stepId ? stepWorkerIdById.get(options.stepId) : undefined) || undefined,
1800
+ message: options?.message || undefined,
1801
+ metadata: options?.metadata || undefined,
1802
+ });
1803
+ };
1804
+ try {
1302
1805
  for (let index = 0; index < actions.length; index += 1) {
1303
1806
  this.assertNotCancelled(job.job_id);
1304
1807
  const action = actions[index];
1305
1808
  const progressPercent = Math.max(10, Math.round(((index + 1) / actions.length) * 100));
1809
+ currentActionStepId = runtimeStepIdForActionIndex(runtimeManifest, index);
1810
+ const stepReporter = bindStepReporter(reporter, currentActionStepId);
1811
+ if (workspaceContext) {
1812
+ assertActionAllowedByWorkspacePolicy(workspaceContext, action.type);
1813
+ }
1814
+ appendHookEvent("pre_tool_use", {
1815
+ stepId: currentActionStepId,
1816
+ message: `Preparando ${action.type}.`,
1817
+ metadata: {
1818
+ action_type: action.type,
1819
+ action_index: index,
1820
+ },
1821
+ });
1822
+ const reportActionProgress = async (progressMessage) => {
1823
+ await stepReporter.progress(progressPercent, progressMessage);
1824
+ };
1825
+ const appendActionArtifact = (kind, payload) => {
1826
+ appendInlineRuntimeArtifact(artifacts, runtimeManifest, index, kind, payload, currentActionStepId);
1827
+ };
1306
1828
  if (action.type === "open_app") {
1307
- await reporter.progress(progressPercent, `Abrindo ${action.app} no macOS`);
1829
+ await reportActionProgress(`Abrindo ${action.app} no macOS`);
1308
1830
  await this.openApp(action.app);
1831
+ resultPayload.last_app_action = {
1832
+ action: "open_app",
1833
+ app: action.app,
1834
+ focused: true,
1835
+ };
1836
+ appendActionArtifact("app_state", {
1837
+ summary: `${action.app} foi aberto no macOS.`,
1838
+ app: action.app,
1839
+ });
1309
1840
  completionNotes.push(`${action.app} foi aberto no macOS.`);
1841
+ appendHookEvent("post_tool_use", {
1842
+ stepId: currentActionStepId,
1843
+ message: `${action.type} concluido.`,
1844
+ metadata: { action_type: action.type, status: "completed" },
1845
+ });
1310
1846
  continue;
1311
1847
  }
1312
1848
  if (action.type === "focus_app") {
1313
- await reporter.progress(progressPercent, `Trazendo ${action.app} para frente`);
1849
+ await reportActionProgress(`Trazendo ${action.app} para frente`);
1314
1850
  await this.focusApp(action.app);
1851
+ resultPayload.last_app_action = {
1852
+ action: "focus_app",
1853
+ app: action.app,
1854
+ focused: true,
1855
+ };
1856
+ appendActionArtifact("app_state", {
1857
+ summary: `${action.app} ficou em foco no macOS.`,
1858
+ app: action.app,
1859
+ });
1315
1860
  completionNotes.push(`${action.app} ficou em foco no macOS.`);
1861
+ appendHookEvent("post_tool_use", {
1862
+ stepId: currentActionStepId,
1863
+ message: `${action.type} concluido.`,
1864
+ metadata: { action_type: action.type, status: "completed" },
1865
+ });
1316
1866
  continue;
1317
1867
  }
1318
1868
  if (action.type === "press_shortcut") {
1319
- await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
1869
+ await reportActionProgress(`Enviando atalho ${action.shortcut}`);
1320
1870
  const shortcutResult = await this.pressShortcut(action.shortcut);
1871
+ resultPayload.last_shortcut = {
1872
+ shortcut: action.shortcut,
1873
+ performed: shortcutResult.performed,
1874
+ reason: shortcutResult.reason || null,
1875
+ };
1876
+ appendActionArtifact("app_state", {
1877
+ summary: shortcutResult.performed
1878
+ ? `Atalho ${action.shortcut} enviado no macOS.`
1879
+ : (shortcutResult.reason || `O atalho ${action.shortcut} foi pulado.`),
1880
+ shortcut: action.shortcut,
1881
+ performed: shortcutResult.performed,
1882
+ });
1321
1883
  if (action.shortcut.startsWith("media_")) {
1322
1884
  const mediaSummaryMap = {
1323
1885
  media_next: "Acionei o comando de próxima mídia no macOS.",
@@ -1339,14 +1901,23 @@ export class NativeMacOSJobExecutor {
1339
1901
  continue;
1340
1902
  }
1341
1903
  if (action.type === "create_note") {
1342
- await reporter.progress(progressPercent, "Criando nota no Notes");
1904
+ await reportActionProgress("Criando nota no Notes");
1343
1905
  const noteTitle = await this.createNote(action.text, action.title);
1906
+ resultPayload.note = {
1907
+ title: noteTitle,
1908
+ text_preview: clipText(action.text, 180),
1909
+ };
1910
+ appendActionArtifact("local_note", {
1911
+ summary: `Nota criada no Notes: ${noteTitle}`,
1912
+ title: noteTitle,
1913
+ mime_type: "text/plain",
1914
+ });
1344
1915
  completionNotes.push(`Nota criada no Notes: ${noteTitle}`);
1345
1916
  continue;
1346
1917
  }
1347
1918
  if (action.type === "type_text") {
1348
1919
  const typingApp = this.lastActiveApp || await this.getFrontmostAppName();
1349
- await reporter.progress(progressPercent, `Digitando texto em ${typingApp || "app ativo"}`);
1920
+ await reportActionProgress(`Digitando texto em ${typingApp || "app ativo"}`);
1350
1921
  const typed = await this.guidedTypeText(action.text, typingApp || undefined);
1351
1922
  if (!typed.ok) {
1352
1923
  throw new Error(typed.reason || "Nao consegui digitar o texto no app ativo.");
@@ -1358,6 +1929,15 @@ export class NativeMacOSJobExecutor {
1358
1929
  attempts: typed.attempts,
1359
1930
  text_preview: clipText(action.text, 180),
1360
1931
  };
1932
+ appendActionArtifact("typed_text", {
1933
+ summary: typed.verified
1934
+ ? `Digitei e confirmei o texto no ${typed.app || "app ativo"}.`
1935
+ : `Digitei o texto no ${typed.app || "app ativo"}.`,
1936
+ mime_type: "text/plain",
1937
+ app: typed.app || null,
1938
+ verified: typed.verified,
1939
+ text_preview: clipText(action.text, 180),
1940
+ });
1361
1941
  this.lastVisualTargetDescription = null;
1362
1942
  this.lastVisualTargetApp = null;
1363
1943
  completionNotes.push(typed.verified
@@ -1366,14 +1946,16 @@ export class NativeMacOSJobExecutor {
1366
1946
  continue;
1367
1947
  }
1368
1948
  if (action.type === "take_screenshot") {
1369
- await reporter.progress(progressPercent, "Capturando screenshot do Mac");
1949
+ await reportActionProgress("Capturando screenshot do Mac");
1370
1950
  const screenshotPath = await this.takeScreenshot(action.path);
1371
1951
  const uploadable = await this.buildUploadableImage(screenshotPath);
1952
+ const expectedArtifactId = runtimeExpectedArtifactIdForActionIndex(runtimeManifest, index, "screenshot");
1372
1953
  const screenshotArtifact = await this.uploadArtifactForJob(job.job_id, uploadable.path, {
1373
1954
  kind: "screenshot",
1374
1955
  mimeTypeOverride: uploadable.mimeType,
1375
1956
  fileNameOverride: uploadable.filename,
1376
1957
  metadata: {
1958
+ expected_artifact_id: expectedArtifactId || undefined,
1377
1959
  visible_in_chat: true,
1378
1960
  width: uploadable.dimensions?.width || undefined,
1379
1961
  height: uploadable.dimensions?.height || undefined,
@@ -1393,14 +1975,14 @@ export class NativeMacOSJobExecutor {
1393
1975
  continue;
1394
1976
  }
1395
1977
  if (action.type === "read_frontmost_page") {
1396
- await reporter.progress(progressPercent, `Lendo a pagina ativa em ${action.app || "Safari"}`);
1978
+ await reportActionProgress(`Lendo a pagina ativa em ${action.app || "Safari"}`);
1397
1979
  const page = await this.readFrontmostPage(action.app || "Safari");
1398
1980
  this.lastReadFrontmostPage = {
1399
1981
  app: action.app || "Safari",
1400
1982
  ...page,
1401
1983
  };
1402
1984
  if (!page.text && this.bridgeConfig?.apiBaseUrl && this.bridgeConfig?.deviceToken) {
1403
- await reporter.progress(progressPercent, "Safari bloqueou leitura direta; vou analisar a pagina pela tela");
1985
+ await reportActionProgress("Safari bloqueou leitura direta; vou analisar a pagina pela tela");
1404
1986
  const screenshotPath = await this.takeScreenshot();
1405
1987
  const uploadable = await this.buildUploadableImage(screenshotPath);
1406
1988
  const artifact = await this.uploadArtifactForJob(job.job_id, uploadable.path, {
@@ -1429,73 +2011,424 @@ export class NativeMacOSJobExecutor {
1429
2011
  }
1430
2012
  }
1431
2013
  resultPayload.page = page;
2014
+ appendActionArtifact("page_snapshot", {
2015
+ summary: `Li a pagina ${page.title || page.url || "ativa"} no navegador.`,
2016
+ mime_type: "text/plain",
2017
+ title: page.title || undefined,
2018
+ path: page.url || undefined,
2019
+ });
1432
2020
  completionNotes.push(`Li a pagina ${page.title || page.url || "ativa"} no navegador.`);
1433
2021
  continue;
1434
2022
  }
1435
2023
  if (action.type === "browser_context") {
1436
- await reporter.progress(progressPercent, "Lendo o contexto do navegador ativo");
2024
+ await reportActionProgress("Lendo o contexto do navegador ativo");
1437
2025
  const browserContext = await this.collectBrowserContext(action.app, action.include_text === true, action.include_tabs === true);
1438
2026
  resultPayload.browser_context = browserContext;
1439
2027
  resultPayload.summary = browserContext.summary;
2028
+ appendActionArtifact("browser_context", {
2029
+ summary: browserContext.summary,
2030
+ title: browserContext.title || undefined,
2031
+ path: browserContext.url || undefined,
2032
+ });
1440
2033
  completionNotes.push(browserContext.summary);
1441
2034
  continue;
1442
2035
  }
1443
2036
  if (action.type === "app_status") {
1444
- await reporter.progress(progressPercent, "Lendo os apps ativos do Mac");
2037
+ await reportActionProgress("Lendo os apps ativos do Mac");
1445
2038
  const appStatus = await this.collectAppStatus(action.app, action.include_frontmost === true, action.include_running_apps === true, action.include_top_processes === true);
1446
2039
  resultPayload.app_status = appStatus;
1447
2040
  resultPayload.summary = appStatus.summary;
2041
+ appendActionArtifact("app_status", {
2042
+ summary: appStatus.summary,
2043
+ app: action.app || appStatus.frontmost_app || undefined,
2044
+ });
1448
2045
  completionNotes.push(appStatus.summary);
1449
2046
  continue;
1450
2047
  }
1451
2048
  if (action.type === "filesystem_inspect") {
1452
- await reporter.progress(progressPercent, `Inspecionando ${action.path}`);
1453
- const filesystem = await this.inspectFilesystemPath(action.path, action.include_children === true, action.include_preview === true, action.limit);
2049
+ await reportActionProgress(`Inspecionando ${action.path}`);
2050
+ const filesystem = await this.inspectFilesystemPath(action.path, action.include_children === true, action.include_preview === true, action.limit, workspaceContext);
1454
2051
  resultPayload.filesystem = filesystem;
1455
2052
  resultPayload.summary = filesystem.summary;
2053
+ appendActionArtifact("filesystem_snapshot", {
2054
+ summary: filesystem.summary,
2055
+ path: filesystem.resolved_path,
2056
+ });
1456
2057
  completionNotes.push(filesystem.summary);
2058
+ appendHookEvent("post_tool_use", {
2059
+ stepId: currentActionStepId,
2060
+ message: `${action.type} concluido.`,
2061
+ metadata: { action_type: action.type, status: "completed" },
2062
+ });
1457
2063
  continue;
1458
2064
  }
1459
2065
  if (action.type === "read_file") {
1460
- await reporter.progress(progressPercent, `Lendo ${action.path}`);
1461
- const fileContent = await this.readLocalFileSnapshot(action.path, action.max_chars);
2066
+ await reportActionProgress(`Lendo ${action.path}`);
2067
+ const fileContent = await this.readLocalFileSnapshot(action.path, action.max_chars, workspaceContext);
1462
2068
  resultPayload.read_file = fileContent;
1463
2069
  resultPayload.summary = fileContent.summary;
2070
+ appendActionArtifact("file_content", {
2071
+ summary: fileContent.summary,
2072
+ path: fileContent.resolved_path,
2073
+ mime_type: fileContent.mime_type,
2074
+ filename: fileContent.name,
2075
+ });
1464
2076
  completionNotes.push(fileContent.summary);
2077
+ appendHookEvent("post_tool_use", {
2078
+ stepId: currentActionStepId,
2079
+ message: `${action.type} concluido.`,
2080
+ metadata: { action_type: action.type, status: "completed" },
2081
+ });
1465
2082
  continue;
1466
2083
  }
1467
2084
  if (action.type === "trash_path") {
1468
- await reporter.progress(progressPercent, `Movendo ${action.path} para a Lixeira`);
1469
- const trashed = await this.movePathToTrashSnapshot(action.path);
2085
+ await reportActionProgress(`Movendo ${action.path} para a Lixeira`);
2086
+ const trashed = await this.movePathToTrashSnapshot(action.path, workspaceContext);
1470
2087
  resultPayload.trash_path = trashed;
1471
2088
  resultPayload.summary = trashed.summary;
2089
+ appendActionArtifact("trash_receipt", {
2090
+ summary: trashed.summary,
2091
+ path: trashed.trashed_path,
2092
+ filename: trashed.name,
2093
+ });
1472
2094
  completionNotes.push(trashed.summary);
2095
+ appendHookEvent("post_tool_use", {
2096
+ stepId: currentActionStepId,
2097
+ message: `${action.type} concluido.`,
2098
+ metadata: { action_type: action.type, status: "completed" },
2099
+ });
2100
+ continue;
2101
+ }
2102
+ if (action.type === "delete_file") {
2103
+ await reportActionProgress(`Apagando arquivo local ${action.path}`);
2104
+ const deleted = await this.deleteLocalFileSnapshot(action.path, workspaceContext);
2105
+ resultPayload.delete_file = deleted;
2106
+ resultPayload.summary = deleted.summary;
2107
+ appendActionArtifact("delete_receipt", {
2108
+ summary: deleted.summary,
2109
+ path: deleted.resolved_path,
2110
+ filename: deleted.name,
2111
+ });
2112
+ completionNotes.push(deleted.summary);
2113
+ appendHookEvent("post_tool_use", {
2114
+ stepId: currentActionStepId,
2115
+ message: `${action.type} concluido.`,
2116
+ metadata: { action_type: action.type, status: "completed" },
2117
+ });
1473
2118
  continue;
1474
2119
  }
1475
2120
  if (action.type === "write_text_file") {
1476
2121
  const targetLabel = action.filename ? `${action.path}/${action.filename}` : action.path;
1477
- await reporter.progress(progressPercent, `Escrevendo arquivo de texto em ${targetLabel}`);
2122
+ await reportActionProgress(`Escrevendo arquivo de texto em ${targetLabel}`);
1478
2123
  const resolvedContent = this.resolveWriteTextFileContent(action);
1479
2124
  if (!resolvedContent) {
1480
2125
  throw new Error("Nenhum texto foi informado para gravar no arquivo local.");
1481
2126
  }
1482
- const written = await this.writeTextFileSnapshot(action.path, resolvedContent.content, action.filename, action.append === true, resolvedContent.source);
2127
+ const written = await this.writeTextFileSnapshot(action.path, resolvedContent.content, action.filename, action.append === true, resolvedContent.source, workspaceContext);
1483
2128
  resultPayload.write_text_file = written;
1484
2129
  resultPayload.summary = written.summary;
2130
+ appendActionArtifact("local_file", {
2131
+ summary: written.summary,
2132
+ path: written.resolved_path,
2133
+ filename: written.name,
2134
+ mime_type: written.mime_type,
2135
+ });
2136
+ completionNotes.push(written.summary);
2137
+ appendHookEvent("post_tool_use", {
2138
+ stepId: currentActionStepId,
2139
+ message: `${action.type} concluido.`,
2140
+ metadata: { action_type: action.type, status: "completed" },
2141
+ });
2142
+ continue;
2143
+ }
2144
+ if (action.type === "write_json_file") {
2145
+ const targetLabel = action.filename ? `${action.path}/${action.filename}` : action.path;
2146
+ await reportActionProgress(`Escrevendo arquivo JSON em ${targetLabel}`);
2147
+ const written = await this.writeJsonFileSnapshot(action.path, action.data, action.filename, action.pretty !== false, workspaceContext);
2148
+ resultPayload.write_json_file = written;
2149
+ resultPayload.summary = written.summary;
2150
+ appendActionArtifact("local_file", {
2151
+ summary: written.summary,
2152
+ path: written.resolved_path,
2153
+ filename: written.name,
2154
+ mime_type: written.mime_type,
2155
+ });
1485
2156
  completionNotes.push(written.summary);
2157
+ appendHookEvent("post_tool_use", {
2158
+ stepId: currentActionStepId,
2159
+ message: `${action.type} concluido.`,
2160
+ metadata: { action_type: action.type, status: "completed" },
2161
+ });
2162
+ continue;
2163
+ }
2164
+ if (action.type === "apply_patch") {
2165
+ await reportActionProgress(`Aplicando patch local em ${action.cwd}`);
2166
+ const patchSet = await this.applyPatchSnapshot(action.patch, action.cwd, workspaceContext);
2167
+ resultPayload.patch_set = patchSet;
2168
+ resultPayload.summary = patchSet.summary;
2169
+ appendActionArtifact("patch_set", {
2170
+ summary: patchSet.summary,
2171
+ path: patchSet.resolved_cwd,
2172
+ mime_type: "application/json",
2173
+ });
2174
+ completionNotes.push(patchSet.summary);
2175
+ appendHookEvent("post_tool_use", {
2176
+ stepId: currentActionStepId,
2177
+ message: `${action.type} concluido.`,
2178
+ metadata: { action_type: action.type, status: "completed" },
2179
+ });
2180
+ continue;
2181
+ }
2182
+ if (action.type === "mkdir") {
2183
+ await reportActionProgress(`Criando pasta em ${action.path}`);
2184
+ const created = await this.createDirectorySnapshot(action.path, action.create_parents !== false, workspaceContext);
2185
+ resultPayload.mkdir = created;
2186
+ resultPayload.summary = created.summary;
2187
+ appendActionArtifact("directory_receipt", {
2188
+ summary: created.summary,
2189
+ path: created.resolved_path,
2190
+ filename: created.name,
2191
+ });
2192
+ completionNotes.push(created.summary);
2193
+ appendHookEvent("post_tool_use", {
2194
+ stepId: currentActionStepId,
2195
+ message: `${action.type} concluido.`,
2196
+ metadata: { action_type: action.type, status: "completed" },
2197
+ });
2198
+ continue;
2199
+ }
2200
+ if (action.type === "move_file") {
2201
+ await reportActionProgress(`Movendo item local de ${action.source_path} para ${action.destination_path}`);
2202
+ const moved = await this.moveLocalPathSnapshot(action.source_path, action.destination_path, action.overwrite === true, workspaceContext);
2203
+ resultPayload.move_file = moved;
2204
+ resultPayload.summary = moved.summary;
2205
+ appendActionArtifact("move_receipt", {
2206
+ summary: moved.summary,
2207
+ path: moved.resolved_destination_path,
2208
+ filename: moved.name,
2209
+ });
2210
+ completionNotes.push(moved.summary);
2211
+ appendHookEvent("post_tool_use", {
2212
+ stepId: currentActionStepId,
2213
+ message: `${action.type} concluido.`,
2214
+ metadata: { action_type: action.type, status: "completed" },
2215
+ });
2216
+ continue;
2217
+ }
2218
+ if (action.type === "git_clone") {
2219
+ await reportActionProgress(`Clonando repositório em ${action.destination_path}`);
2220
+ const gitClone = await this.gitCloneSnapshot(action.repository, action.destination_path, {
2221
+ branch: action.branch,
2222
+ depth: action.depth,
2223
+ }, workspaceContext);
2224
+ resultPayload.git_clone = gitClone;
2225
+ resultPayload.summary = gitClone.summary;
2226
+ appendActionArtifact("git_clone_receipt", {
2227
+ summary: gitClone.summary,
2228
+ path: gitClone.repo_root || gitClone.resolved_destination_path,
2229
+ mime_type: "application/json",
2230
+ });
2231
+ completionNotes.push(gitClone.summary);
2232
+ appendHookEvent("post_tool_use", {
2233
+ stepId: currentActionStepId,
2234
+ message: `${action.type} concluido.`,
2235
+ metadata: { action_type: action.type, status: gitClone.cloned ? "completed" : "failed" },
2236
+ });
2237
+ continue;
2238
+ }
2239
+ if (action.type === "git_fetch") {
2240
+ await reportActionProgress(`Atualizando refs remotos do Git em ${action.cwd}`);
2241
+ const gitFetch = await this.gitFetchSnapshot(action.cwd, {
2242
+ remote: action.remote,
2243
+ prune: action.prune === true,
2244
+ tags: action.tags === true,
2245
+ }, workspaceContext);
2246
+ resultPayload.git_fetch = gitFetch;
2247
+ resultPayload.summary = gitFetch.summary;
2248
+ appendActionArtifact("git_fetch_receipt", {
2249
+ summary: gitFetch.summary,
2250
+ path: gitFetch.repo_root || gitFetch.resolved_cwd,
2251
+ mime_type: "application/json",
2252
+ });
2253
+ completionNotes.push(gitFetch.summary);
2254
+ appendHookEvent("post_tool_use", {
2255
+ stepId: currentActionStepId,
2256
+ message: `${action.type} concluido.`,
2257
+ metadata: { action_type: action.type, status: gitFetch.fetched === false ? "failed" : "completed" },
2258
+ });
2259
+ continue;
2260
+ }
2261
+ if (action.type === "git_checkout") {
2262
+ await reportActionProgress(`Trocando branch local em ${action.cwd}`);
2263
+ const gitCheckout = await this.gitCheckoutSnapshot(action.cwd, {
2264
+ target: action.target,
2265
+ startPoint: action.start_point,
2266
+ createBranch: action.create_branch === true,
2267
+ detach: action.detach === true,
2268
+ }, workspaceContext);
2269
+ resultPayload.git_checkout = gitCheckout;
2270
+ resultPayload.summary = gitCheckout.summary;
2271
+ appendActionArtifact("git_checkout_receipt", {
2272
+ summary: gitCheckout.summary,
2273
+ path: gitCheckout.repo_root || gitCheckout.resolved_cwd,
2274
+ mime_type: "application/json",
2275
+ });
2276
+ completionNotes.push(gitCheckout.summary);
2277
+ appendHookEvent("post_tool_use", {
2278
+ stepId: currentActionStepId,
2279
+ message: `${action.type} concluido.`,
2280
+ metadata: { action_type: action.type, status: gitCheckout.switched === false ? "failed" : "completed" },
2281
+ });
2282
+ continue;
2283
+ }
2284
+ if (action.type === "git_rebase") {
2285
+ await reportActionProgress(`Rebaseando a branch atual em ${action.cwd}`);
2286
+ const gitRebase = await this.gitRebaseSnapshot(action.cwd, {
2287
+ target: action.target,
2288
+ autostash: action.autostash === true,
2289
+ }, workspaceContext);
2290
+ resultPayload.git_rebase = gitRebase;
2291
+ resultPayload.summary = gitRebase.summary;
2292
+ appendActionArtifact("git_rebase_receipt", {
2293
+ summary: gitRebase.summary,
2294
+ path: gitRebase.repo_root || gitRebase.resolved_cwd,
2295
+ mime_type: "application/json",
2296
+ });
2297
+ completionNotes.push(gitRebase.summary);
2298
+ appendHookEvent("post_tool_use", {
2299
+ stepId: currentActionStepId,
2300
+ message: `${action.type} concluido.`,
2301
+ metadata: { action_type: action.type, status: gitRebase.rebased === false ? "failed" : "completed" },
2302
+ });
2303
+ continue;
2304
+ }
2305
+ if (action.type === "git_merge") {
2306
+ await reportActionProgress(`Mesclando ${action.target} em ${action.cwd}`);
2307
+ const gitMerge = await this.gitMergeSnapshot(action.cwd, {
2308
+ target: action.target,
2309
+ ffOnly: action.ff_only === true,
2310
+ noFf: action.no_ff === true,
2311
+ message: action.message,
2312
+ }, workspaceContext);
2313
+ resultPayload.git_merge = gitMerge;
2314
+ resultPayload.summary = gitMerge.summary;
2315
+ appendActionArtifact("git_merge_receipt", {
2316
+ summary: gitMerge.summary,
2317
+ path: gitMerge.repo_root || gitMerge.resolved_cwd,
2318
+ mime_type: "application/json",
2319
+ });
2320
+ completionNotes.push(gitMerge.summary);
2321
+ appendHookEvent("post_tool_use", {
2322
+ stepId: currentActionStepId,
2323
+ message: `${action.type} concluido.`,
2324
+ metadata: { action_type: action.type, status: gitMerge.merged === false ? "failed" : "completed" },
2325
+ });
2326
+ continue;
2327
+ }
2328
+ if (action.type === "git_tag") {
2329
+ await reportActionProgress(`Criando a tag ${action.name} em ${action.cwd}`);
2330
+ const gitTag = await this.gitTagSnapshot(action.cwd, {
2331
+ name: action.name,
2332
+ target: action.target,
2333
+ annotated: action.annotated === true,
2334
+ message: action.message,
2335
+ }, workspaceContext);
2336
+ resultPayload.git_tag = gitTag;
2337
+ resultPayload.summary = gitTag.summary;
2338
+ appendActionArtifact("git_tag_receipt", {
2339
+ summary: gitTag.summary,
2340
+ path: gitTag.repo_root || gitTag.resolved_cwd,
2341
+ mime_type: "application/json",
2342
+ });
2343
+ completionNotes.push(gitTag.summary);
2344
+ appendHookEvent("post_tool_use", {
2345
+ stepId: currentActionStepId,
2346
+ message: `${action.type} concluido.`,
2347
+ metadata: { action_type: action.type, status: gitTag.created === false ? "failed" : "completed" },
2348
+ });
2349
+ continue;
2350
+ }
2351
+ if (action.type === "git_add") {
2352
+ await reportActionProgress(`Preparando stage do Git em ${action.cwd}`);
2353
+ const gitAdd = await this.gitAddSnapshot(action.cwd, {
2354
+ paths: action.paths,
2355
+ all: action.all === true,
2356
+ }, workspaceContext);
2357
+ resultPayload.git_add = gitAdd;
2358
+ resultPayload.summary = gitAdd.summary;
2359
+ appendActionArtifact("git_stage_receipt", {
2360
+ summary: gitAdd.summary,
2361
+ path: gitAdd.repo_root || gitAdd.resolved_cwd,
2362
+ mime_type: "application/json",
2363
+ });
2364
+ completionNotes.push(gitAdd.summary);
2365
+ appendHookEvent("post_tool_use", {
2366
+ stepId: currentActionStepId,
2367
+ message: `${action.type} concluido.`,
2368
+ metadata: { action_type: action.type, status: gitAdd.error_message ? "failed" : "completed" },
2369
+ });
2370
+ continue;
2371
+ }
2372
+ if (action.type === "git_commit") {
2373
+ await reportActionProgress(`Criando commit local em ${action.cwd}`);
2374
+ const gitCommit = await this.gitCommitSnapshot(action.cwd, action.message, action.allow_empty === true, workspaceContext);
2375
+ resultPayload.git_commit = gitCommit;
2376
+ resultPayload.summary = gitCommit.summary;
2377
+ appendActionArtifact("git_commit_receipt", {
2378
+ summary: gitCommit.summary,
2379
+ path: gitCommit.repo_root || gitCommit.resolved_cwd,
2380
+ mime_type: "application/json",
2381
+ });
2382
+ completionNotes.push(gitCommit.summary);
2383
+ appendHookEvent("post_tool_use", {
2384
+ stepId: currentActionStepId,
2385
+ message: `${action.type} concluido.`,
2386
+ metadata: { action_type: action.type, status: gitCommit.committed === false ? "failed" : "completed" },
2387
+ });
2388
+ continue;
2389
+ }
2390
+ if (action.type === "git_push") {
2391
+ await reportActionProgress(`Enviando branch local para o remote em ${action.cwd}`);
2392
+ const gitPush = await this.gitPushSnapshot(action.cwd, {
2393
+ remote: action.remote,
2394
+ branch: action.branch,
2395
+ setUpstream: action.set_upstream === true,
2396
+ }, workspaceContext);
2397
+ resultPayload.git_push = gitPush;
2398
+ resultPayload.summary = gitPush.summary;
2399
+ appendActionArtifact("git_push_receipt", {
2400
+ summary: gitPush.summary,
2401
+ path: gitPush.repo_root || gitPush.resolved_cwd,
2402
+ mime_type: "application/json",
2403
+ });
2404
+ completionNotes.push(gitPush.summary);
2405
+ appendHookEvent("post_tool_use", {
2406
+ stepId: currentActionStepId,
2407
+ message: `${action.type} concluido.`,
2408
+ metadata: { action_type: action.type, status: gitPush.pushed === false ? "failed" : "completed" },
2409
+ });
1486
2410
  continue;
1487
2411
  }
1488
2412
  if (action.type === "list_files") {
1489
- await reporter.progress(progressPercent, `Listando arquivos em ${action.path}`);
1490
- const listing = await this.listLocalFilesSnapshot(action.path, action.limit);
2413
+ await reportActionProgress(`Listando arquivos em ${action.path}`);
2414
+ const listing = await this.listLocalFilesSnapshot(action.path, action.limit, workspaceContext);
1491
2415
  resultPayload.file_listing = listing;
1492
2416
  resultPayload.summary = listing.summary;
2417
+ appendActionArtifact("file_listing", {
2418
+ summary: listing.summary,
2419
+ path: listing.resolved_path,
2420
+ });
1493
2421
  completionNotes.push(listing.summary);
2422
+ appendHookEvent("post_tool_use", {
2423
+ stepId: currentActionStepId,
2424
+ message: `${action.type} concluido.`,
2425
+ metadata: { action_type: action.type, status: "completed" },
2426
+ });
1494
2427
  continue;
1495
2428
  }
1496
2429
  if (action.type === "count_files") {
1497
- await reporter.progress(progressPercent, `Contando arquivos em ${action.path}`);
1498
- const counted = await this.countLocalFiles(action.path, action.extensions, action.recursive !== false);
2430
+ await reportActionProgress(`Contando arquivos em ${action.path}`);
2431
+ const counted = await this.countLocalFiles(action.path, action.extensions, action.recursive !== false, workspaceContext);
1499
2432
  completionNotes.push(`Encontrei ${counted.total} arquivo${counted.total === 1 ? "" : "s"} ${counted.extensionsLabel} em ${counted.path}.`);
1500
2433
  resultPayload.file_count = {
1501
2434
  total: counted.total,
@@ -1503,48 +2436,161 @@ export class NativeMacOSJobExecutor {
1503
2436
  extensions: counted.extensions,
1504
2437
  recursive: counted.recursive,
1505
2438
  };
2439
+ appendActionArtifact("file_count", {
2440
+ summary: `Encontrei ${counted.total} arquivo${counted.total === 1 ? "" : "s"} ${counted.extensionsLabel} em ${counted.path}.`,
2441
+ path: counted.path,
2442
+ });
2443
+ appendHookEvent("post_tool_use", {
2444
+ stepId: currentActionStepId,
2445
+ message: `${action.type} concluido.`,
2446
+ metadata: { action_type: action.type, status: "completed" },
2447
+ });
1506
2448
  continue;
1507
2449
  }
1508
2450
  if (action.type === "system_status") {
1509
- await reporter.progress(progressPercent, "Lendo CPU, memoria, disco e bateria do Mac");
2451
+ await reportActionProgress("Lendo CPU, memoria, disco e bateria do Mac");
1510
2452
  const systemStatus = await this.collectSystemStatus(action.sections, action.include_top_processes === true);
1511
2453
  resultPayload.system_status = systemStatus;
1512
2454
  resultPayload.summary = systemStatus.summary;
2455
+ appendActionArtifact("system_status", {
2456
+ summary: systemStatus.summary,
2457
+ });
1513
2458
  completionNotes.push(systemStatus.summary);
1514
2459
  continue;
1515
2460
  }
1516
- if (action.type === "run_shell") {
1517
- await reporter.progress(progressPercent, `Rodando comando local: ${action.command}`);
1518
- const shellOutput = await this.runShellCommand(action.command, action.cwd);
1519
- completionNotes.push(`Saida de \`${action.command}\`:\n${shellOutput}`);
2461
+ if (action.type === "git_status") {
2462
+ await reportActionProgress(`Lendo o estado do Git em ${action.cwd}`);
2463
+ const gitStatus = await this.gitStatusSnapshot(action.cwd, action.include_untracked !== false, workspaceContext);
2464
+ resultPayload.git_status = gitStatus;
2465
+ resultPayload.summary = gitStatus.summary;
2466
+ appendActionArtifact("git_status_report", {
2467
+ summary: gitStatus.summary,
2468
+ path: gitStatus.repo_root || gitStatus.resolved_cwd,
2469
+ mime_type: "application/json",
2470
+ });
2471
+ completionNotes.push(gitStatus.summary);
2472
+ appendHookEvent("post_tool_use", {
2473
+ stepId: currentActionStepId,
2474
+ message: `${action.type} concluido.`,
2475
+ metadata: { action_type: action.type, status: "completed" },
2476
+ });
1520
2477
  continue;
1521
2478
  }
1522
- if (action.type === "set_volume") {
1523
- await reporter.progress(progressPercent, `Ajustando volume para ${action.level}%`);
1524
- await this.setVolume(action.level);
1525
- completionNotes.push(`Volume ajustado para ${action.level}% no macOS.`);
2479
+ if (action.type === "git_diff") {
2480
+ await reportActionProgress(`Lendo o diff do Git em ${action.cwd}`);
2481
+ const gitDiff = await this.gitDiffSnapshot(action.cwd, {
2482
+ staged: action.staged === true,
2483
+ baseRef: action.base_ref,
2484
+ paths: action.paths,
2485
+ maxChars: action.max_chars,
2486
+ }, workspaceContext);
2487
+ resultPayload.git_diff = gitDiff;
2488
+ resultPayload.summary = gitDiff.summary;
2489
+ appendActionArtifact("git_diff_report", {
2490
+ summary: gitDiff.summary,
2491
+ path: gitDiff.repo_root || gitDiff.resolved_cwd,
2492
+ mime_type: "text/plain",
2493
+ });
2494
+ completionNotes.push(gitDiff.summary);
2495
+ appendHookEvent("post_tool_use", {
2496
+ stepId: currentActionStepId,
2497
+ message: `${action.type} concluido.`,
2498
+ metadata: { action_type: action.type, status: "completed" },
2499
+ });
1526
2500
  continue;
1527
2501
  }
1528
- if (action.type === "scroll_view") {
1529
- const scrollApp = action.app || this.lastActiveApp || await this.getFrontmostAppName();
1530
- if (scrollApp) {
1531
- await reporter.progress(progressPercent, `Trazendo ${scrollApp} para frente antes de rolar a tela`);
1532
- await this.focusApp(scrollApp);
1533
- }
1534
- const directionLabel = action.direction === "up" ? "cima" : "baixo";
1535
- await reporter.progress(progressPercent, `Rolando a tela para ${directionLabel}`);
1536
- await this.scrollView(action.direction, action.amount, action.steps);
1537
- resultPayload.last_scroll = {
2502
+ if (action.type === "run_tests") {
2503
+ await reportActionProgress(`Rodando testes em ${action.cwd}`);
2504
+ appendHookEvent("validation_ladder_started", {
2505
+ stepId: currentActionStepId,
2506
+ message: "Validation ladder iniciada.",
2507
+ metadata: {
2508
+ requested_profile: action.profile || undefined,
2509
+ ladder_id: runtimeManifest.validationLadder?.ladder_id || undefined,
2510
+ },
2511
+ });
2512
+ const testReport = await this.runTestsSnapshot(action.command, action.cwd, action.timeout_seconds, workspaceContext, action.profile, runtimeManifest);
2513
+ resultPayload.test_report = testReport;
2514
+ resultPayload.summary = testReport.summary;
2515
+ appendActionArtifact("test_report", {
2516
+ summary: testReport.summary,
2517
+ path: testReport.resolved_cwd,
2518
+ mime_type: "text/plain",
2519
+ });
2520
+ completionNotes.push(testReport.summary);
2521
+ appendHookEvent("validation_ladder_completed", {
2522
+ stepId: currentActionStepId,
2523
+ message: testReport.summary,
2524
+ metadata: {
2525
+ requested_profile: action.profile || undefined,
2526
+ stage_count: testReport.stage_count || 0,
2527
+ failed_stage_id: testReport.failed_stage_id || undefined,
2528
+ passed: testReport.passed,
2529
+ },
2530
+ });
2531
+ appendHookEvent("post_tool_use", {
2532
+ stepId: currentActionStepId,
2533
+ message: `${action.type} concluido.`,
2534
+ metadata: { action_type: action.type, status: testReport.passed ? "completed" : "failed" },
2535
+ });
2536
+ continue;
2537
+ }
2538
+ if (action.type === "run_shell") {
2539
+ await reportActionProgress(`Rodando comando local: ${action.command}`);
2540
+ const shellOutput = await this.runShellCommand(action.command, action.cwd, workspaceContext);
2541
+ resultPayload.shell_result = {
2542
+ command: action.command,
2543
+ cwd: workspaceContext ? assertCwdInsideWorkspace(workspaceContext, action.cwd) : (action.cwd || null),
2544
+ output: shellOutput,
2545
+ };
2546
+ appendActionArtifact("shell_result", {
2547
+ summary: `Comando executado: ${action.command}`,
2548
+ mime_type: "text/plain",
2549
+ });
2550
+ completionNotes.push(`Saida de \`${action.command}\`:\n${shellOutput}`);
2551
+ appendHookEvent("post_tool_use", {
2552
+ stepId: currentActionStepId,
2553
+ message: `${action.type} concluido.`,
2554
+ metadata: { action_type: action.type, status: "completed" },
2555
+ });
2556
+ continue;
2557
+ }
2558
+ if (action.type === "set_volume") {
2559
+ await reportActionProgress(`Ajustando volume para ${action.level}%`);
2560
+ await this.setVolume(action.level);
2561
+ resultPayload.device_state = {
2562
+ volume_level: action.level,
2563
+ };
2564
+ appendActionArtifact("device_state_change", {
2565
+ summary: `Volume ajustado para ${action.level}% no macOS.`,
2566
+ });
2567
+ completionNotes.push(`Volume ajustado para ${action.level}% no macOS.`);
2568
+ continue;
2569
+ }
2570
+ if (action.type === "scroll_view") {
2571
+ const scrollApp = action.app || this.lastActiveApp || await this.getFrontmostAppName();
2572
+ if (scrollApp) {
2573
+ await reportActionProgress(`Trazendo ${scrollApp} para frente antes de rolar a tela`);
2574
+ await this.focusApp(scrollApp);
2575
+ }
2576
+ const directionLabel = action.direction === "up" ? "cima" : "baixo";
2577
+ await reportActionProgress(`Rolando a tela para ${directionLabel}`);
2578
+ await this.scrollView(action.direction, action.amount, action.steps);
2579
+ resultPayload.last_scroll = {
1538
2580
  direction: action.direction,
1539
2581
  amount: action.amount || "medium",
1540
2582
  steps: action.steps || 1,
1541
2583
  app: scrollApp || null,
1542
2584
  };
2585
+ appendActionArtifact("viewport_change", {
2586
+ summary: `Rolei a tela para ${directionLabel} no macOS.`,
2587
+ app: scrollApp || null,
2588
+ });
1543
2589
  completionNotes.push(`Rolei a tela para ${directionLabel} no macOS.`);
1544
2590
  continue;
1545
2591
  }
1546
2592
  if (action.type === "whatsapp_send_message") {
1547
- await reporter.progress(progressPercent, `Abrindo a conversa do WhatsApp com ${action.contact}`);
2593
+ await reportActionProgress(`Abrindo a conversa do WhatsApp com ${action.contact}`);
1548
2594
  await this.ensureWhatsAppWebReady();
1549
2595
  const selected = await this.selectWhatsAppConversation(action.contact);
1550
2596
  if (!selected) {
@@ -1554,7 +2600,7 @@ export class NativeMacOSJobExecutor {
1554
2600
  messages: [],
1555
2601
  summary: "",
1556
2602
  }));
1557
- await reporter.progress(progressPercent, `Enviando a mensagem para ${action.contact} no WhatsApp`);
2603
+ await reportActionProgress(`Enviando a mensagem para ${action.contact} no WhatsApp`);
1558
2604
  await this.sendWhatsAppMessage(action.text);
1559
2605
  await delay(900);
1560
2606
  const afterSend = await this.readWhatsAppVisibleConversation(action.contact, Math.max(12, beforeSend.messages.length + 4)).catch(() => null);
@@ -1565,17 +2611,22 @@ export class NativeMacOSJobExecutor {
1565
2611
  messages: afterSend?.messages || [],
1566
2612
  summary: afterSend?.summary || "",
1567
2613
  };
2614
+ appendActionArtifact("message_delivery", {
2615
+ summary: `Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`,
2616
+ contact: action.contact,
2617
+ });
1568
2618
  const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1569
2619
  if (!verification.ok) {
1570
2620
  resultPayload.summary = verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`;
1571
- await reporter.failed(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`, resultPayload);
2621
+ attachWorkspaceMemory("failed");
2622
+ await stepReporter.failed(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`, resultPayload);
1572
2623
  return;
1573
2624
  }
1574
2625
  completionNotes.push(`Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`);
1575
2626
  continue;
1576
2627
  }
1577
2628
  if (action.type === "whatsapp_read_chat") {
1578
- await reporter.progress(progressPercent, `Abrindo a conversa do WhatsApp com ${action.contact}`);
2629
+ await reportActionProgress(`Abrindo a conversa do WhatsApp com ${action.contact}`);
1579
2630
  await this.ensureWhatsAppWebReady();
1580
2631
  const selected = await this.selectWhatsAppConversation(action.contact);
1581
2632
  if (!selected) {
@@ -1588,17 +2639,21 @@ export class NativeMacOSJobExecutor {
1588
2639
  contact: action.contact,
1589
2640
  messages: chat.messages,
1590
2641
  };
2642
+ appendActionArtifact("message_snapshot", {
2643
+ summary: `Mensagens visiveis no WhatsApp com ${action.contact}.`,
2644
+ contact: action.contact,
2645
+ });
1591
2646
  completionNotes.push(`Mensagens visiveis no WhatsApp com ${action.contact}:\n${chat.summary}`);
1592
2647
  continue;
1593
2648
  }
1594
2649
  if (action.type === "click_visual_target") {
1595
2650
  const browserApp = await this.resolveLikelyBrowserApp(action.app);
1596
2651
  if (browserApp) {
1597
- await reporter.progress(progressPercent, `Trazendo ${browserApp} para frente antes do clique`);
2652
+ await reportActionProgress(`Trazendo ${browserApp} para frente antes do clique`);
1598
2653
  await this.focusApp(browserApp);
1599
2654
  }
1600
2655
  else if (action.app) {
1601
- await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do clique`);
2656
+ await reportActionProgress(`Trazendo ${action.app} para frente antes do clique`);
1602
2657
  await this.focusApp(action.app);
1603
2658
  }
1604
2659
  const targetDescriptions = isSpotifySafariDomOnlyStep(action.description)
@@ -1614,7 +2669,7 @@ export class NativeMacOSJobExecutor {
1614
2669
  const isSpotifySafariStep = browserApp === "Safari" && isSpotifySafariDomOnlyStep(targetDescription);
1615
2670
  const verificationPrompt = isSpotifySafariStep ? undefined : action.verification_prompt;
1616
2671
  if (isSpotifySafariStep) {
1617
- await reporter.progress(progressPercent, `Tentando concluir ${targetDescription} pelo DOM do Spotify no Safari`);
2672
+ await reportActionProgress(`Tentando concluir ${targetDescription} pelo DOM do Spotify no Safari`);
1618
2673
  const spotifyDomResult = await this.executeSpotifySafariDomStep(targetDescription, initialBrowserState);
1619
2674
  if (spotifyDomResult.ok) {
1620
2675
  this.rememberSatisfiedSpotifyStep(targetDescription, !!spotifyDomResult.confirmedPlaying);
@@ -1640,7 +2695,7 @@ export class NativeMacOSJobExecutor {
1640
2695
  }
1641
2696
  const nativeMediaTransport = extractNativeMediaTransportCommand(targetDescription);
1642
2697
  if (nativeMediaTransport) {
1643
- await reporter.progress(progressPercent, `Tentando controle de mídia nativo do macOS para ${targetDescription}`);
2698
+ await reportActionProgress(`Tentando controle de mídia nativo do macOS para ${targetDescription}`);
1644
2699
  try {
1645
2700
  await this.triggerMacOSMediaTransport(nativeMediaTransport);
1646
2701
  let validated = false;
@@ -1651,7 +2706,7 @@ export class NativeMacOSJobExecutor {
1651
2706
  validationReason = browserValidation.reason;
1652
2707
  }
1653
2708
  if (!validated && verificationPrompt) {
1654
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "native_media_transport_result");
2709
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, stepReporter, artifacts, "native_media_transport_result");
1655
2710
  if (verification.unavailable) {
1656
2711
  lastFailureReason = verification.reason;
1657
2712
  break;
@@ -1672,14 +2727,14 @@ export class NativeMacOSJobExecutor {
1672
2727
  break;
1673
2728
  }
1674
2729
  lastFailureReason = validationReason || `O controle de mídia nativo do macOS nao confirmou ${targetDescription}.`;
1675
- await reporter.progress(progressPercent, "O controle de mídia nativo nao foi suficiente; vou tentar DOM/OCR");
2730
+ await reportActionProgress("O controle de mídia nativo nao foi suficiente; vou tentar DOM/OCR");
1676
2731
  }
1677
2732
  catch (error) {
1678
2733
  lastFailureReason = error instanceof Error ? error.message : String(error);
1679
2734
  }
1680
2735
  }
1681
2736
  if (browserApp === "Safari") {
1682
- await reporter.progress(progressPercent, `Tentando localizar ${targetDescription} diretamente no Safari`);
2737
+ await reportActionProgress(`Tentando localizar ${targetDescription} diretamente no Safari`);
1683
2738
  const domClick = await this.trySafariDomClick(targetDescription);
1684
2739
  if (domClick?.clicked) {
1685
2740
  let validated = false;
@@ -1690,7 +2745,7 @@ export class NativeMacOSJobExecutor {
1690
2745
  validationReason = browserValidation.reason;
1691
2746
  }
1692
2747
  if (!validated && verificationPrompt) {
1693
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "dom_click_result");
2748
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, stepReporter, artifacts, "dom_click_result");
1694
2749
  if (verification.unavailable) {
1695
2750
  lastFailureReason = verification.reason;
1696
2751
  break;
@@ -1724,7 +2779,7 @@ export class NativeMacOSJobExecutor {
1724
2779
  const visualBeforeState = browserApp
1725
2780
  ? await this.captureBrowserPageState(browserApp).catch(() => initialBrowserState)
1726
2781
  : initialBrowserState;
1727
- await reporter.progress(progressPercent, `Capturando a tela para localizar ${targetDescription}`);
2782
+ await reportActionProgress(`Capturando a tela para localizar ${targetDescription}`);
1728
2783
  let screenshotPath = await this.takeScreenshot();
1729
2784
  const ocrClick = await this.tryLocalOcrClick(screenshotPath, targetDescription);
1730
2785
  if (ocrClick.clicked) {
@@ -1736,7 +2791,7 @@ export class NativeMacOSJobExecutor {
1736
2791
  validationReason = browserValidation.reason;
1737
2792
  }
1738
2793
  if (!validated && verificationPrompt) {
1739
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
2794
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, stepReporter, artifacts, "local_ocr_click_result");
1740
2795
  if (verification.unavailable) {
1741
2796
  lastFailureReason = verification.reason;
1742
2797
  break;
@@ -1765,7 +2820,7 @@ export class NativeMacOSJobExecutor {
1765
2820
  break;
1766
2821
  }
1767
2822
  lastFailureReason = validationReason || `O clique por OCR local em ${targetDescription} nao teve efeito confirmavel.`;
1768
- await reporter.progress(progressPercent, "OCR local nao confirmou o clique; vou tentar visão remota");
2823
+ await reportActionProgress("OCR local nao confirmou o clique; vou tentar visão remota");
1769
2824
  screenshotPath = await this.takeScreenshot();
1770
2825
  }
1771
2826
  else if (ocrClick.reason) {
@@ -1806,7 +2861,7 @@ export class NativeMacOSJobExecutor {
1806
2861
  }
1807
2862
  continue;
1808
2863
  }
1809
- await reporter.progress(progressPercent, `Clicando em ${targetDescription}`);
2864
+ await reportActionProgress(`Clicando em ${targetDescription}`);
1810
2865
  const scaledX = width > 0 && originalWidth > 0 ? (location.x / width) * originalWidth : location.x;
1811
2866
  const scaledY = height > 0 && originalHeight > 0 ? (location.y / height) * originalHeight : location.y;
1812
2867
  await this.clickPoint(scaledX, scaledY);
@@ -1817,7 +2872,7 @@ export class NativeMacOSJobExecutor {
1817
2872
  strategy: "visual_locator",
1818
2873
  };
1819
2874
  if (verificationPrompt) {
1820
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "visual_click_result");
2875
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, stepReporter, artifacts, "visual_click_result");
1821
2876
  if (verification.unavailable) {
1822
2877
  lastFailureReason = verification.reason;
1823
2878
  break;
@@ -1843,19 +2898,22 @@ export class NativeMacOSJobExecutor {
1843
2898
  if (!clickSucceeded) {
1844
2899
  throw new Error(lastFailureReason || `Nao consegui concluir o clique visual para ${action.description}.`);
1845
2900
  }
2901
+ appendActionArtifact("interaction_result", {
2902
+ summary: `Localizei e cliquei em ${action.description}.`,
2903
+ });
1846
2904
  continue;
1847
2905
  }
1848
2906
  if (action.type === "drag_visual_target") {
1849
2907
  const dragApp = await this.resolveLikelyBrowserApp(action.app);
1850
2908
  if (dragApp) {
1851
- await reporter.progress(progressPercent, `Trazendo ${dragApp} para frente antes do arraste`);
2909
+ await reportActionProgress(`Trazendo ${dragApp} para frente antes do arraste`);
1852
2910
  await this.focusApp(dragApp);
1853
2911
  }
1854
2912
  else if (action.app) {
1855
- await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do arraste`);
2913
+ await reportActionProgress(`Trazendo ${action.app} para frente antes do arraste`);
1856
2914
  await this.focusApp(action.app);
1857
2915
  }
1858
- await reporter.progress(progressPercent, `Capturando a tela para localizar ${action.source_description} e ${action.target_description}`);
2916
+ await reportActionProgress(`Capturando a tela para localizar ${action.source_description} e ${action.target_description}`);
1859
2917
  const screenshotPath = await this.takeScreenshot();
1860
2918
  const sourcePoint = await this.resolveVisualTargetPoint(job.job_id, screenshotPath, action.source_description, artifacts, "drag_source");
1861
2919
  const targetPoint = await this.resolveVisualTargetPoint(job.job_id, screenshotPath, action.target_description, artifacts, "drag_target");
@@ -1865,18 +2923,29 @@ export class NativeMacOSJobExecutor {
1865
2923
  if (!targetPoint) {
1866
2924
  throw new Error(`Nao consegui localizar ${action.target_description} com confianca suficiente para concluir o arraste.`);
1867
2925
  }
1868
- await reporter.progress(progressPercent, `Arrastando ${action.source_description} para ${action.target_description}`);
2926
+ await reportActionProgress(`Arrastando ${action.source_description} para ${action.target_description}`);
1869
2927
  await this.dragPoint(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
1870
2928
  resultPayload.last_drag = {
1871
2929
  source: sourcePoint,
1872
2930
  target: targetPoint,
1873
2931
  };
2932
+ appendActionArtifact("interaction_result", {
2933
+ summary: `Arrastei ${action.source_description} para ${action.target_description}.`,
2934
+ });
1874
2935
  completionNotes.push(`Arrastei ${action.source_description} para ${action.target_description}.`);
1875
2936
  continue;
1876
2937
  }
1877
- await reporter.progress(progressPercent, `Abrindo ${action.url}${action.app ? ` em ${action.app}` : ""}`);
2938
+ await reportActionProgress(`Abrindo ${action.url}${action.app ? ` em ${action.app}` : ""}`);
1878
2939
  await this.openUrl(action.url, action.app);
1879
2940
  await delay(1200);
2941
+ resultPayload.last_navigation = {
2942
+ url: action.url,
2943
+ app: action.app || null,
2944
+ };
2945
+ appendActionArtifact("navigation_result", {
2946
+ summary: `${humanizeUrl(action.url)} foi aberto${action.app ? ` em ${action.app}` : ""}.`,
2947
+ path: action.url,
2948
+ });
1880
2949
  completionNotes.push(`${humanizeUrl(action.url)} foi aberto${action.app ? ` em ${action.app}` : ""}.`);
1881
2950
  }
1882
2951
  const summary = completionNotes.length > 0
@@ -1884,8 +2953,47 @@ export class NativeMacOSJobExecutor {
1884
2953
  : (actions.length === 1
1885
2954
  ? this.describeAction(actions[0])
1886
2955
  : `${actions.length} ações executadas no macOS`);
2956
+ attachWorkspaceMemory("completed");
2957
+ if (hookTrace.length > 0) {
2958
+ artifacts.push({
2959
+ id: `runtime_hook_trace.${job.job_id}`,
2960
+ kind: "runtime_hook_trace",
2961
+ summary: `Hook trace do runtime com ${hookTrace.length} eventos.`,
2962
+ metadata: {
2963
+ event_count: hookTrace.length,
2964
+ graph_id: runtimeManifest.graphId || undefined,
2965
+ },
2966
+ });
2967
+ }
1887
2968
  resultPayload.summary = summary;
1888
- await reporter.completed(resultPayload);
2969
+ await reporter.completed(resultPayload, {
2970
+ stepId: runtimeManifest.finalizeStepId,
2971
+ });
2972
+ }
2973
+ catch (error) {
2974
+ if (error instanceof JobCancelledError) {
2975
+ throw error;
2976
+ }
2977
+ const detail = error instanceof Error ? error.message : String(error);
2978
+ attachWorkspaceMemory("failed");
2979
+ if (hookTrace.length > 0 && !artifacts.some((artifact) => artifact.id === `runtime_hook_trace.${job.job_id}`)) {
2980
+ artifacts.push({
2981
+ id: `runtime_hook_trace.${job.job_id}`,
2982
+ kind: "runtime_hook_trace",
2983
+ summary: `Hook trace do runtime com ${hookTrace.length} eventos.`,
2984
+ metadata: {
2985
+ event_count: hookTrace.length,
2986
+ graph_id: runtimeManifest.graphId || undefined,
2987
+ },
2988
+ });
2989
+ }
2990
+ await reporter.failed(detail || "Otto Bridge native-macos failed", {
2991
+ ...resultPayload,
2992
+ summary: String(resultPayload.summary || detail || "").trim() || undefined,
2993
+ }, {
2994
+ stepId: currentActionStepId || runtimeManifest.executionStepId,
2995
+ });
2996
+ return;
1889
2997
  }
1890
2998
  finally {
1891
2999
  this.cancelledJobs.delete(job.job_id);
@@ -2376,7 +3484,10 @@ return appNames as text
2376
3484
  status.summary = this.buildAppStatusSummary(status);
2377
3485
  return status;
2378
3486
  }
2379
- async resolveFilesystemInspectPath(targetPath) {
3487
+ async resolveFilesystemInspectPath(targetPath, workspaceContext) {
3488
+ if (workspaceContext) {
3489
+ return assertPathInsideWorkspace(workspaceContext, targetPath);
3490
+ }
2380
3491
  const expanded = expandUserPath(targetPath);
2381
3492
  try {
2382
3493
  await stat(expanded);
@@ -2403,8 +3514,8 @@ return appNames as text
2403
3514
  return 0;
2404
3515
  }
2405
3516
  }
2406
- async inspectFilesystemPath(targetPath, includeChildren = true, includePreview = false, limit = 8) {
2407
- const resolved = await this.resolveFilesystemInspectPath(targetPath);
3517
+ async inspectFilesystemPath(targetPath, includeChildren = true, includePreview = false, limit = 8, workspaceContext) {
3518
+ const resolved = await this.resolveFilesystemInspectPath(targetPath, workspaceContext);
2408
3519
  const entryStat = await stat(resolved);
2409
3520
  const itemName = path.basename(resolved) || resolved;
2410
3521
  if (entryStat.isDirectory()) {
@@ -5140,8 +6251,8 @@ if let output = String(data: data, encoding: .utf8) {
5140
6251
  }
5141
6252
  return clipTextPreview(loaded.content || "(arquivo vazio)", maxChars);
5142
6253
  }
5143
- async readLocalFileSnapshot(filePath, chunkSizeChars = 4000) {
5144
- const resolved = await this.resolveReadableFilePath(filePath);
6254
+ async readLocalFileSnapshot(filePath, chunkSizeChars = 4000, workspaceContext) {
6255
+ const resolved = await this.resolveReadableFilePath(filePath, workspaceContext);
5145
6256
  const entryStat = await stat(resolved);
5146
6257
  const loaded = await this.loadReadableFileContent(resolved);
5147
6258
  const fileName = path.basename(resolved) || resolved;
@@ -5179,7 +6290,10 @@ if let output = String(data: data, encoding: .utf8) {
5179
6290
  summary: `Li ${filePath} por completo (${content.length} caracteres em ${chunks.length} bloco${chunks.length === 1 ? "" : "s"}).`,
5180
6291
  };
5181
6292
  }
5182
- async resolveTrashTargetPath(targetPath) {
6293
+ async resolveTrashTargetPath(targetPath, workspaceContext) {
6294
+ if (workspaceContext) {
6295
+ return assertPathInsideWorkspace(workspaceContext, targetPath);
6296
+ }
5183
6297
  const resolved = expandUserPath(targetPath);
5184
6298
  try {
5185
6299
  await stat(resolved);
@@ -5268,8 +6382,8 @@ if let output = String(data: data, encoding: .utf8) {
5268
6382
  }
5269
6383
  }
5270
6384
  }
5271
- async movePathToTrashSnapshot(targetPath) {
5272
- const resolved = await this.resolveTrashTargetPath(targetPath);
6385
+ async movePathToTrashSnapshot(targetPath, workspaceContext) {
6386
+ const resolved = await this.resolveTrashTargetPath(targetPath, workspaceContext);
5273
6387
  const entryStat = await stat(resolved);
5274
6388
  const trashDir = path.join(os.homedir(), ".Trash");
5275
6389
  await mkdir(trashDir, { recursive: true });
@@ -5293,39 +6407,55 @@ if let output = String(data: data, encoding: .utf8) {
5293
6407
  summary: `${kind === "directory" ? "Mandei a pasta" : kind === "file" ? "Mandei o arquivo" : "Mandei o item"} ${name} para a Lixeira.`,
5294
6408
  };
5295
6409
  }
5296
- async resolveWritableTextFilePath(targetPath, filename) {
5297
- const expanded = expandUserPath(targetPath);
6410
+ async resolveWritableTextFilePath(targetPath, filename, workspaceContext) {
6411
+ const expanded = workspaceContext
6412
+ ? expandUserPathLike(targetPath, workspaceContext.defaultCwd)
6413
+ : expandUserPath(targetPath);
5298
6414
  const requestedFilename = filename ? sanitizeFileName(filename) : null;
5299
6415
  if (requestedFilename) {
5300
6416
  try {
5301
6417
  const existingStat = await stat(expanded);
5302
6418
  if (existingStat.isDirectory()) {
5303
- return path.join(expanded, requestedFilename);
6419
+ const resolvedDirectoryPath = path.join(expanded, requestedFilename);
6420
+ return workspaceContext
6421
+ ? assertPathInsideWorkspace(workspaceContext, resolvedDirectoryPath)
6422
+ : resolvedDirectoryPath;
5304
6423
  }
5305
6424
  }
5306
6425
  catch {
5307
6426
  // Continue below and treat the target as a direct file path.
5308
6427
  }
5309
6428
  if (String(targetPath || "").trimEnd().endsWith(path.sep)) {
5310
- return path.join(expanded, requestedFilename);
6429
+ const resolvedDirectoryPath = path.join(expanded, requestedFilename);
6430
+ return workspaceContext
6431
+ ? assertPathInsideWorkspace(workspaceContext, resolvedDirectoryPath)
6432
+ : resolvedDirectoryPath;
5311
6433
  }
5312
6434
  }
5313
6435
  try {
5314
6436
  const existingStat = await stat(expanded);
5315
6437
  if (existingStat.isDirectory()) {
5316
- return path.join(expanded, sanitizeFileName("otto-note.txt"));
6438
+ const fallbackDirectoryPath = path.join(expanded, sanitizeFileName("otto-note.txt"));
6439
+ return workspaceContext
6440
+ ? assertPathInsideWorkspace(workspaceContext, fallbackDirectoryPath)
6441
+ : fallbackDirectoryPath;
5317
6442
  }
5318
6443
  }
5319
6444
  catch {
5320
6445
  // Continue below and treat the target as a direct file path.
5321
6446
  }
5322
6447
  if (String(targetPath || "").trimEnd().endsWith(path.sep)) {
5323
- return path.join(expanded, sanitizeFileName("otto-note.txt"));
6448
+ const fallbackDirectoryPath = path.join(expanded, sanitizeFileName("otto-note.txt"));
6449
+ return workspaceContext
6450
+ ? assertPathInsideWorkspace(workspaceContext, fallbackDirectoryPath)
6451
+ : fallbackDirectoryPath;
5324
6452
  }
5325
- return expanded;
6453
+ return workspaceContext
6454
+ ? assertPathInsideWorkspace(workspaceContext, expanded)
6455
+ : expanded;
5326
6456
  }
5327
- async writeTextFileSnapshot(targetPath, text, filename, append = false, source) {
5328
- const resolved = await this.resolveWritableTextFilePath(targetPath, filename);
6457
+ async writeTextFileSnapshot(targetPath, text, filename, append = false, source, workspaceContext) {
6458
+ const resolved = await this.resolveWritableTextFilePath(targetPath, filename, workspaceContext);
5329
6459
  const parentDir = path.dirname(resolved);
5330
6460
  await mkdir(parentDir, { recursive: true });
5331
6461
  await writeFile(resolved, text, {
@@ -5350,6 +6480,281 @@ if let output = String(data: data, encoding: .utf8) {
5350
6480
  summary: `${append ? "Atualizei" : "Escrevi"} ${String(text || "").length} caractere${String(text || "").length === 1 ? "" : "s"} em ${resolved}.`,
5351
6481
  };
5352
6482
  }
6483
+ async writeJsonFileSnapshot(targetPath, data, filename, pretty = true, workspaceContext) {
6484
+ const serialized = JSON.stringify(data, null, pretty ? 2 : 0);
6485
+ if (typeof serialized !== "string") {
6486
+ throw new Error("Nao consegui serializar o JSON pedido para gravar no arquivo local.");
6487
+ }
6488
+ const resolved = await this.resolveWritableTextFilePath(targetPath, filename, workspaceContext);
6489
+ const parentDir = path.dirname(resolved);
6490
+ await mkdir(parentDir, { recursive: true });
6491
+ await writeFile(resolved, serialized, { encoding: "utf8", flag: "w" });
6492
+ const entryStat = await stat(resolved);
6493
+ const name = path.basename(resolved) || resolved;
6494
+ return {
6495
+ captured_at: new Date().toISOString(),
6496
+ path: targetPath,
6497
+ resolved_path: resolved,
6498
+ name,
6499
+ mime_type: "application/json; charset=utf-8",
6500
+ size_bytes: entryStat.size,
6501
+ modified_at: entryStat.mtime.toISOString(),
6502
+ content_char_count: serialized.length,
6503
+ content_preview: clipTextPreview(serialized || "{}", 240),
6504
+ pretty,
6505
+ summary: `Gravei ${serialized.length} caractere${serialized.length === 1 ? "" : "s"} de JSON em ${resolved}.`,
6506
+ };
6507
+ }
6508
+ resolvePatchTargetPath(targetPath, resolvedCwd, workspaceContext) {
6509
+ return workspaceContext
6510
+ ? assertPathInsideWorkspace(workspaceContext, targetPath, { baseCwd: resolvedCwd })
6511
+ : expandUserPathLike(targetPath, resolvedCwd);
6512
+ }
6513
+ countTextLines(value) {
6514
+ const normalized = String(value || "").replace(/\r\n/g, "\n");
6515
+ if (!normalized) {
6516
+ return 0;
6517
+ }
6518
+ const trimmed = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
6519
+ return trimmed ? trimmed.split("\n").length : 0;
6520
+ }
6521
+ async applyPatchOperationSnapshot(operation, resolvedCwd, workspaceContext) {
6522
+ if (operation.type === "add") {
6523
+ const resolvedPath = this.resolvePatchTargetPath(operation.path, resolvedCwd, workspaceContext);
6524
+ try {
6525
+ await stat(resolvedPath);
6526
+ throw new Error(`O apply_patch nao pode adicionar ${resolvedPath} porque o arquivo ja existe.`);
6527
+ }
6528
+ catch (error) {
6529
+ if (error?.code !== "ENOENT") {
6530
+ throw error;
6531
+ }
6532
+ }
6533
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
6534
+ const fileContent = operation.content_lines.length > 0 ? `${operation.content_lines.join("\n")}\n` : "";
6535
+ await writeFile(resolvedPath, fileContent, "utf8");
6536
+ return {
6537
+ path: operation.path,
6538
+ resolved_path: resolvedPath,
6539
+ status: "added",
6540
+ added_line_count: operation.content_lines.length,
6541
+ deleted_line_count: 0,
6542
+ };
6543
+ }
6544
+ if (operation.type === "delete") {
6545
+ const resolvedPath = this.resolvePatchTargetPath(operation.path, resolvedCwd, workspaceContext);
6546
+ const entryStat = await stat(resolvedPath);
6547
+ if (entryStat.isDirectory()) {
6548
+ throw new Error(`O apply_patch nao suporta remover pastas: ${resolvedPath}.`);
6549
+ }
6550
+ let deletedLineCount = 0;
6551
+ try {
6552
+ deletedLineCount = this.countTextLines(await readFile(resolvedPath, "utf8"));
6553
+ }
6554
+ catch {
6555
+ deletedLineCount = 0;
6556
+ }
6557
+ await unlink(resolvedPath);
6558
+ return {
6559
+ path: operation.path,
6560
+ resolved_path: resolvedPath,
6561
+ status: "deleted",
6562
+ added_line_count: 0,
6563
+ deleted_line_count: deletedLineCount,
6564
+ };
6565
+ }
6566
+ const resolvedSourcePath = this.resolvePatchTargetPath(operation.path, resolvedCwd, workspaceContext);
6567
+ const originalText = await readFile(resolvedSourcePath, "utf8");
6568
+ const applied = applyStructuredUpdateToText(originalText, operation);
6569
+ const resolvedDestinationPath = operation.move_to
6570
+ ? this.resolvePatchTargetPath(operation.move_to, resolvedCwd, workspaceContext)
6571
+ : resolvedSourcePath;
6572
+ if (operation.move_to && resolvedDestinationPath !== resolvedSourcePath) {
6573
+ try {
6574
+ await stat(resolvedDestinationPath);
6575
+ throw new Error(`O apply_patch nao pode mover para ${resolvedDestinationPath} porque o destino ja existe.`);
6576
+ }
6577
+ catch (error) {
6578
+ if (error?.code !== "ENOENT") {
6579
+ throw error;
6580
+ }
6581
+ }
6582
+ await mkdir(path.dirname(resolvedDestinationPath), { recursive: true });
6583
+ await writeFile(resolvedDestinationPath, applied.text, "utf8");
6584
+ await unlink(resolvedSourcePath);
6585
+ return {
6586
+ path: operation.move_to || operation.path,
6587
+ resolved_path: resolvedDestinationPath,
6588
+ status: "moved",
6589
+ previous_path: operation.path,
6590
+ resolved_previous_path: resolvedSourcePath,
6591
+ added_line_count: applied.addedLineCount,
6592
+ deleted_line_count: applied.deletedLineCount,
6593
+ };
6594
+ }
6595
+ await writeFile(resolvedSourcePath, applied.text, "utf8");
6596
+ return {
6597
+ path: operation.path,
6598
+ resolved_path: resolvedSourcePath,
6599
+ status: "updated",
6600
+ added_line_count: applied.addedLineCount,
6601
+ deleted_line_count: applied.deletedLineCount,
6602
+ };
6603
+ }
6604
+ async applyPatchSnapshot(patchText, cwd, workspaceContext) {
6605
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd, workspaceContext);
6606
+ const parsed = parseStructuredPatch(patchText);
6607
+ if (parsed.operations.length === 0) {
6608
+ throw new Error("O apply_patch nao recebeu nenhuma operacao valida.");
6609
+ }
6610
+ const files = [];
6611
+ for (const operation of parsed.operations) {
6612
+ files.push(await this.applyPatchOperationSnapshot(operation, resolvedCwd, workspaceContext));
6613
+ }
6614
+ const addedCount = files.filter((item) => item.status === "added").length;
6615
+ const updatedCount = files.filter((item) => item.status === "updated").length;
6616
+ const deletedCount = files.filter((item) => item.status === "deleted").length;
6617
+ const movedCount = files.filter((item) => item.status === "moved").length;
6618
+ const changedFiles = uniqueStrings(files.map((item) => item.path));
6619
+ const changedFileCount = files.length;
6620
+ const summary = changedFileCount === 0
6621
+ ? `O patch em ${resolvedCwd} nao gerou mudancas persistidas.`
6622
+ : `Apliquei um patch em ${resolvedCwd} com ${changedFileCount} arquivo${changedFileCount === 1 ? "" : "s"} alterado${changedFileCount === 1 ? "" : "s"} (${addedCount} adicionados, ${updatedCount} atualizados, ${deletedCount} removidos, ${movedCount} movidos).`;
6623
+ return {
6624
+ captured_at: new Date().toISOString(),
6625
+ cwd,
6626
+ resolved_cwd: resolvedCwd,
6627
+ patch_char_count: parsed.patch_char_count,
6628
+ operation_count: parsed.operations.length,
6629
+ changed_file_count: changedFileCount,
6630
+ added_count: addedCount,
6631
+ updated_count: updatedCount,
6632
+ deleted_count: deletedCount,
6633
+ moved_count: movedCount,
6634
+ changed_files: changedFiles,
6635
+ files,
6636
+ summary,
6637
+ };
6638
+ }
6639
+ async createDirectorySnapshot(targetPath, createParents = true, workspaceContext) {
6640
+ const resolved = workspaceContext
6641
+ ? assertPathInsideWorkspace(workspaceContext, targetPath)
6642
+ : expandUserPath(targetPath);
6643
+ let existedBefore = false;
6644
+ try {
6645
+ const existingStat = await stat(resolved);
6646
+ if (!existingStat.isDirectory()) {
6647
+ throw new Error(`Ja existe um item em ${resolved}, mas ele nao e uma pasta.`);
6648
+ }
6649
+ existedBefore = true;
6650
+ }
6651
+ catch (error) {
6652
+ if (error?.code !== "ENOENT") {
6653
+ throw error;
6654
+ }
6655
+ }
6656
+ await mkdir(resolved, { recursive: createParents });
6657
+ const directoryStat = await stat(resolved);
6658
+ return {
6659
+ captured_at: new Date().toISOString(),
6660
+ path: targetPath,
6661
+ resolved_path: resolved,
6662
+ name: path.basename(resolved) || resolved,
6663
+ created: !existedBefore,
6664
+ existed_before: existedBefore,
6665
+ modified_at: directoryStat.mtime.toISOString(),
6666
+ summary: existedBefore
6667
+ ? `A pasta ${resolved} ja existia e ficou pronta para uso.`
6668
+ : `Criei a pasta ${resolved} no macOS.`,
6669
+ };
6670
+ }
6671
+ async resolveMoveDestinationPath(sourceResolvedPath, destinationPath, workspaceContext) {
6672
+ const expanded = workspaceContext
6673
+ ? assertPathInsideWorkspace(workspaceContext, destinationPath)
6674
+ : expandUserPath(destinationPath);
6675
+ try {
6676
+ const destinationStat = await stat(expanded);
6677
+ if (destinationStat.isDirectory()) {
6678
+ const nestedDestination = path.join(expanded, path.basename(sourceResolvedPath));
6679
+ return workspaceContext
6680
+ ? assertPathInsideWorkspace(workspaceContext, nestedDestination)
6681
+ : nestedDestination;
6682
+ }
6683
+ }
6684
+ catch {
6685
+ // Continue below.
6686
+ }
6687
+ if (String(destinationPath || "").trimEnd().endsWith(path.sep)) {
6688
+ const nestedDestination = path.join(expanded, path.basename(sourceResolvedPath));
6689
+ return workspaceContext
6690
+ ? assertPathInsideWorkspace(workspaceContext, nestedDestination)
6691
+ : nestedDestination;
6692
+ }
6693
+ return expanded;
6694
+ }
6695
+ async moveLocalPathSnapshot(sourcePath, destinationPath, overwrite = false, workspaceContext) {
6696
+ const resolvedSourcePath = workspaceContext
6697
+ ? assertPathInsideWorkspace(workspaceContext, sourcePath)
6698
+ : expandUserPath(sourcePath);
6699
+ const sourceStat = await stat(resolvedSourcePath);
6700
+ const resolvedDestinationPath = await this.resolveMoveDestinationPath(resolvedSourcePath, destinationPath, workspaceContext);
6701
+ await mkdir(path.dirname(resolvedDestinationPath), { recursive: true });
6702
+ let overwritten = false;
6703
+ try {
6704
+ const destinationStat = await stat(resolvedDestinationPath);
6705
+ if (!overwrite) {
6706
+ throw new Error(`Ja existe um item em ${resolvedDestinationPath}. Defina overwrite=true se quiser substituir esse arquivo.`);
6707
+ }
6708
+ if (destinationStat.isDirectory()) {
6709
+ throw new Error(`Ja existe uma pasta em ${resolvedDestinationPath}.`);
6710
+ }
6711
+ await unlink(resolvedDestinationPath);
6712
+ overwritten = true;
6713
+ }
6714
+ catch (error) {
6715
+ if (error?.code !== "ENOENT") {
6716
+ throw error;
6717
+ }
6718
+ }
6719
+ await rename(resolvedSourcePath, resolvedDestinationPath);
6720
+ const itemKind = sourceStat.isDirectory()
6721
+ ? "directory"
6722
+ : sourceStat.isFile()
6723
+ ? "file"
6724
+ : "other";
6725
+ return {
6726
+ captured_at: new Date().toISOString(),
6727
+ source_path: sourcePath,
6728
+ resolved_source_path: resolvedSourcePath,
6729
+ destination_path: destinationPath,
6730
+ resolved_destination_path: resolvedDestinationPath,
6731
+ name: path.basename(resolvedDestinationPath) || resolvedDestinationPath,
6732
+ item_kind: itemKind,
6733
+ overwritten,
6734
+ summary: `Movi ${itemKind === "directory" ? "a pasta" : "o item"} ${path.basename(resolvedSourcePath)} para ${resolvedDestinationPath}.`,
6735
+ };
6736
+ }
6737
+ async deleteLocalFileSnapshot(targetPath, workspaceContext) {
6738
+ const resolvedPath = workspaceContext
6739
+ ? assertPathInsideWorkspace(workspaceContext, targetPath)
6740
+ : expandUserPath(targetPath);
6741
+ const entryStat = await stat(resolvedPath);
6742
+ if (entryStat.isDirectory()) {
6743
+ throw new Error("delete_file so suporta arquivos por enquanto. Use trash_path para pastas.");
6744
+ }
6745
+ const itemKind = entryStat.isFile() ? "file" : "other";
6746
+ await unlink(resolvedPath);
6747
+ return {
6748
+ captured_at: new Date().toISOString(),
6749
+ path: targetPath,
6750
+ resolved_path: resolvedPath,
6751
+ name: path.basename(resolvedPath) || resolvedPath,
6752
+ item_kind: itemKind,
6753
+ size_bytes: entryStat.size,
6754
+ modified_at: entryStat.mtime.toISOString(),
6755
+ summary: `Apaguei o arquivo ${resolvedPath} no macOS.`,
6756
+ };
6757
+ }
5353
6758
  resolveWriteTextFileContent(action) {
5354
6759
  const explicitText = [action.text, action.content, action.body]
5355
6760
  .map((value) => String(value || "").trim())
@@ -5366,7 +6771,10 @@ if let output = String(data: data, encoding: .utf8) {
5366
6771
  }
5367
6772
  return null;
5368
6773
  }
5369
- async resolveReadableFilePath(filePath) {
6774
+ async resolveReadableFilePath(filePath, workspaceContext) {
6775
+ if (workspaceContext) {
6776
+ return assertPathInsideWorkspace(workspaceContext, filePath);
6777
+ }
5370
6778
  const resolved = expandUserPath(filePath);
5371
6779
  try {
5372
6780
  await stat(resolved);
@@ -5435,8 +6843,10 @@ if let output = String(data: data, encoding: .utf8) {
5435
6843
  }
5436
6844
  return null;
5437
6845
  }
5438
- async listLocalFilesSnapshot(directoryPath, limit) {
5439
- const resolved = expandUserPath(directoryPath);
6846
+ async listLocalFilesSnapshot(directoryPath, limit, workspaceContext) {
6847
+ const resolved = workspaceContext
6848
+ ? assertPathInsideWorkspace(workspaceContext, directoryPath)
6849
+ : expandUserPath(directoryPath);
5440
6850
  const allEntries = await readdir(resolved, { withFileTypes: true });
5441
6851
  const sortedEntries = allEntries.sort((left, right) => {
5442
6852
  if (left.isDirectory() !== right.isDirectory()) {
@@ -5485,8 +6895,10 @@ if let output = String(data: data, encoding: .utf8) {
5485
6895
  summary,
5486
6896
  };
5487
6897
  }
5488
- async countLocalFiles(directoryPath, extensions, recursive = true) {
5489
- const resolved = expandUserPath(directoryPath);
6898
+ async countLocalFiles(directoryPath, extensions, recursive = true, workspaceContext) {
6899
+ const resolved = workspaceContext
6900
+ ? assertPathInsideWorkspace(workspaceContext, directoryPath)
6901
+ : expandUserPath(directoryPath);
5490
6902
  const normalizedExtensions = Array.from(new Set((extensions || [])
5491
6903
  .map((extension) => String(extension || "").trim().toLowerCase().replace(/^\./, ""))
5492
6904
  .filter(Boolean)));
@@ -5761,11 +7173,1174 @@ if let output = String(data: data, encoding: .utf8) {
5761
7173
  status.summary = this.buildSystemStatusSummary(status);
5762
7174
  return status;
5763
7175
  }
5764
- async runShellCommand(command, cwd) {
7176
+ resolveWorkspaceExecutionCwd(cwd, workspaceContext) {
7177
+ return workspaceContext
7178
+ ? assertCwdInsideWorkspace(workspaceContext, cwd)
7179
+ : (cwd ? expandUserPath(cwd) : process.cwd());
7180
+ }
7181
+ summarizeGitCommandError(result, fallback) {
7182
+ const combined = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n").trim();
7183
+ return clipText(combined || fallback, 4_000);
7184
+ }
7185
+ async probeGitRepository(cwd, workspaceContext) {
7186
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd, workspaceContext);
7187
+ const capturedAt = new Date().toISOString();
7188
+ const repoProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "--show-toplevel"], {
7189
+ cwd: resolvedCwd,
7190
+ allowNonZeroExit: true,
7191
+ });
7192
+ if (repoProbe.exitCode !== 0) {
7193
+ return {
7194
+ capturedAt,
7195
+ resolvedCwd,
7196
+ isRepo: false,
7197
+ };
7198
+ }
7199
+ const repoRoot = repoProbe.stdout.trim() || resolvedCwd;
7200
+ const branchProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "--abbrev-ref", "HEAD"], {
7201
+ cwd: resolvedCwd,
7202
+ allowNonZeroExit: true,
7203
+ });
7204
+ const trackingProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
7205
+ cwd: resolvedCwd,
7206
+ allowNonZeroExit: true,
7207
+ });
7208
+ return {
7209
+ capturedAt,
7210
+ resolvedCwd,
7211
+ isRepo: true,
7212
+ repoRoot,
7213
+ currentBranch: branchProbe.exitCode === 0 ? (branchProbe.stdout.trim() || undefined) : undefined,
7214
+ trackingRef: trackingProbe.exitCode === 0 ? (trackingProbe.stdout.trim() || undefined) : undefined,
7215
+ };
7216
+ }
7217
+ async pathExists(candidatePath) {
7218
+ try {
7219
+ await stat(candidatePath);
7220
+ return true;
7221
+ }
7222
+ catch {
7223
+ return false;
7224
+ }
7225
+ }
7226
+ parseGitFetchUpdatedRefs(output) {
7227
+ const updatedRefs = [];
7228
+ for (const rawLine of output.split(/\r?\n/)) {
7229
+ const line = rawLine.trim();
7230
+ if (!line || !line.includes("->")) {
7231
+ continue;
7232
+ }
7233
+ const refMatch = line.match(/->\s+(.+)$/);
7234
+ if (refMatch?.[1]) {
7235
+ updatedRefs.push(refMatch[1].trim());
7236
+ }
7237
+ }
7238
+ return uniqueStrings(updatedRefs);
7239
+ }
7240
+ async resolveWorkspacePackageManager(resolvedCwd, workspaceContext) {
7241
+ const manifestPackageManagers = uniqueStrings(workspaceContext?.repoManifest?.package_managers || []);
7242
+ for (const packageManager of PACKAGE_MANAGER_PRIORITY) {
7243
+ if (manifestPackageManagers.includes(packageManager)) {
7244
+ return packageManager;
7245
+ }
7246
+ }
7247
+ if (await this.pathExists(path.join(resolvedCwd, "pnpm-lock.yaml"))) {
7248
+ return "pnpm";
7249
+ }
7250
+ if (await this.pathExists(path.join(resolvedCwd, "yarn.lock"))) {
7251
+ return "yarn";
7252
+ }
7253
+ if (await this.pathExists(path.join(resolvedCwd, "bun.lockb"))) {
7254
+ return "bun";
7255
+ }
7256
+ if (await this.pathExists(path.join(resolvedCwd, "package-lock.json")) || await this.pathExists(path.join(resolvedCwd, "package.json"))) {
7257
+ return "npm";
7258
+ }
7259
+ return undefined;
7260
+ }
7261
+ detectWorkspaceValidationStacks(resolvedCwd, workspaceContext) {
7262
+ const detected = new Set();
7263
+ const manifestFiles = uniqueStrings(workspaceContext?.repoManifest?.manifest_files || []);
7264
+ const packageManagers = uniqueStrings(workspaceContext?.repoManifest?.package_managers || []);
7265
+ if (packageManagers.length > 0 || manifestFiles.includes("package.json")) {
7266
+ detected.add("node");
7267
+ }
7268
+ if (manifestFiles.includes("pyproject.toml") || manifestFiles.includes("requirements.txt")) {
7269
+ detected.add("python");
7270
+ }
7271
+ if (detected.size === 0) {
7272
+ detected.add("node");
7273
+ }
7274
+ return Array.from(detected);
7275
+ }
7276
+ resolveValidationLadderStages(manifest, resolvedCwd, workspaceContext) {
7277
+ const ladder = manifest?.validationLadder;
7278
+ if (!ladder || !Array.isArray(ladder.stages) || ladder.stages.length === 0) {
7279
+ return [];
7280
+ }
7281
+ const stacks = this.detectWorkspaceValidationStacks(resolvedCwd, workspaceContext);
7282
+ const selectedStages = ladder.stages
7283
+ .filter((stage) => {
7284
+ if (!stage.profile) {
7285
+ return false;
7286
+ }
7287
+ if (!stage.stack_tags || stage.stack_tags.length === 0) {
7288
+ return true;
7289
+ }
7290
+ return stage.stack_tags.some((tag) => stacks.includes(tag));
7291
+ })
7292
+ .sort((left, right) => (left.order || 0) - (right.order || 0))
7293
+ .map((stage) => ({
7294
+ stage_id: stage.stage_id,
7295
+ title: stage.title,
7296
+ profile: stage.profile,
7297
+ }));
7298
+ const seenProfiles = new Set();
7299
+ return selectedStages.filter((stage) => {
7300
+ if (seenProfiles.has(stage.profile)) {
7301
+ return false;
7302
+ }
7303
+ seenProfiles.add(stage.profile);
7304
+ return true;
7305
+ });
7306
+ }
7307
+ async resolveRunTestsCommand(command, profile, resolvedCwd, workspaceContext) {
7308
+ const explicitCommand = asString(command);
7309
+ if (explicitCommand) {
7310
+ return {
7311
+ command: explicitCommand,
7312
+ resolvedCommand: explicitCommand,
7313
+ profile,
7314
+ };
7315
+ }
7316
+ if (!profile) {
7317
+ throw new Error("Nenhum comando ou profile de validacao foi informado para run_tests.");
7318
+ }
7319
+ if (profile === "auto") {
7320
+ throw new Error("O profile auto precisa ser resolvido pela validation ladder antes de executar run_tests.");
7321
+ }
7322
+ if (profile === "pytest") {
7323
+ const localPytest = path.join(resolvedCwd, ".venv", "bin", "pytest");
7324
+ if (await this.pathExists(localPytest)) {
7325
+ return {
7326
+ command: "./.venv/bin/pytest -q",
7327
+ resolvedCommand: "./.venv/bin/pytest -q",
7328
+ profile,
7329
+ };
7330
+ }
7331
+ if (await this.pathExists(path.join(resolvedCwd, "pyproject.toml"))
7332
+ || await this.pathExists(path.join(resolvedCwd, "pytest.ini"))
7333
+ || await this.pathExists(path.join(resolvedCwd, "requirements.txt"))) {
7334
+ return {
7335
+ command: "python -m pytest -q",
7336
+ resolvedCommand: "python -m pytest -q",
7337
+ profile,
7338
+ };
7339
+ }
7340
+ return {
7341
+ command: "pytest -q",
7342
+ resolvedCommand: "pytest -q",
7343
+ profile,
7344
+ };
7345
+ }
7346
+ if (profile === "node_test") {
7347
+ const packageManager = await this.resolveWorkspacePackageManager(resolvedCwd, workspaceContext);
7348
+ if (!packageManager) {
7349
+ throw new Error(`Nao encontrei um package manager suportado para resolver o profile ${profile} em ${resolvedCwd}.`);
7350
+ }
7351
+ const resolvedCommand = packageManager === "yarn"
7352
+ ? "yarn test"
7353
+ : packageManager === "bun"
7354
+ ? "bun test"
7355
+ : `${packageManager} test`;
7356
+ return {
7357
+ command: resolvedCommand,
7358
+ resolvedCommand,
7359
+ profile,
7360
+ };
7361
+ }
7362
+ if (profile === "npm_test") {
7363
+ return { command: "npm test", resolvedCommand: "npm test", profile };
7364
+ }
7365
+ if (profile === "pnpm_test") {
7366
+ return { command: "pnpm test", resolvedCommand: "pnpm test", profile };
7367
+ }
7368
+ if (profile === "yarn_test") {
7369
+ return { command: "yarn test", resolvedCommand: "yarn test", profile };
7370
+ }
7371
+ if (profile === "bun_test") {
7372
+ return { command: "bun test", resolvedCommand: "bun test", profile };
7373
+ }
7374
+ if (profile === "typecheck") {
7375
+ const localTsc = path.join(resolvedCwd, "node_modules", ".bin", "tsc");
7376
+ if (await this.pathExists(localTsc) && await this.pathExists(path.join(resolvedCwd, "tsconfig.json"))) {
7377
+ return {
7378
+ command: "./node_modules/.bin/tsc --noEmit",
7379
+ resolvedCommand: "./node_modules/.bin/tsc --noEmit",
7380
+ profile,
7381
+ };
7382
+ }
7383
+ const packageManager = await this.resolveWorkspacePackageManager(resolvedCwd, workspaceContext);
7384
+ if (packageManager) {
7385
+ const resolvedCommand = packageManager === "yarn"
7386
+ ? "yarn typecheck"
7387
+ : packageManager === "bun"
7388
+ ? "bun run typecheck"
7389
+ : `${packageManager} run typecheck`;
7390
+ return {
7391
+ command: resolvedCommand,
7392
+ resolvedCommand,
7393
+ profile,
7394
+ };
7395
+ }
7396
+ throw new Error(`Nao encontrei um comando de typecheck suportado para ${resolvedCwd}.`);
7397
+ }
7398
+ const packageManager = await this.resolveWorkspacePackageManager(resolvedCwd, workspaceContext);
7399
+ if (!packageManager) {
7400
+ throw new Error(`Nao encontrei um package manager suportado para resolver o profile ${profile} em ${resolvedCwd}.`);
7401
+ }
7402
+ const scriptName = profile === "lint" ? "lint" : "build";
7403
+ const resolvedCommand = packageManager === "yarn"
7404
+ ? `yarn ${scriptName}`
7405
+ : packageManager === "bun"
7406
+ ? `bun run ${scriptName}`
7407
+ : `${packageManager} run ${scriptName}`;
7408
+ return {
7409
+ command: resolvedCommand,
7410
+ resolvedCommand,
7411
+ profile,
7412
+ };
7413
+ }
7414
+ async resolveGitPathspecsForRepo(repoRoot, resolvedCwd, rawPaths, workspaceContext) {
7415
+ const canonicalRepoRoot = await realpath(repoRoot).catch(() => repoRoot);
7416
+ const cleanedPaths = uniqueStrings((rawPaths || []).map((item) => asString(item)).filter(Boolean));
7417
+ const pathspecs = [];
7418
+ for (const rawPath of cleanedPaths) {
7419
+ const resolvedPath = workspaceContext
7420
+ ? assertPathInsideWorkspace(workspaceContext, rawPath, { baseCwd: resolvedCwd })
7421
+ : expandUserPathLike(rawPath, resolvedCwd);
7422
+ const canonicalResolvedPath = await realpath(resolvedPath).catch(() => resolvedPath);
7423
+ const relativePath = path.relative(canonicalRepoRoot, canonicalResolvedPath) || ".";
7424
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
7425
+ throw new Error(`O caminho ${rawPath} fica fora do repositório Git ativo (${repoRoot}).`);
7426
+ }
7427
+ pathspecs.push(relativePath === "" ? "." : relativePath.split(path.sep).join("/"));
7428
+ }
7429
+ return uniqueStrings(pathspecs);
7430
+ }
7431
+ async gitCloneSnapshot(repository, destinationPath, options, workspaceContext) {
7432
+ const capturedAt = new Date().toISOString();
7433
+ const resolvedDestinationPath = workspaceContext
7434
+ ? assertPathInsideWorkspace(workspaceContext, destinationPath)
7435
+ : expandUserPathLike(destinationPath);
7436
+ const resolvedParentCwd = workspaceContext
7437
+ ? assertCwdInsideWorkspace(workspaceContext, path.dirname(resolvedDestinationPath))
7438
+ : path.dirname(resolvedDestinationPath);
7439
+ const cloneArgs = ["clone"];
7440
+ if (options.depth && options.depth > 0) {
7441
+ cloneArgs.push("--depth", String(options.depth));
7442
+ }
7443
+ const cloneBranch = asString(options.branch) || "";
7444
+ if (cloneBranch) {
7445
+ cloneArgs.push("--branch", cloneBranch);
7446
+ }
7447
+ cloneArgs.push(repository, resolvedDestinationPath);
7448
+ const cloneResult = await this.runCommandCapture("/usr/bin/git", cloneArgs, {
7449
+ cwd: resolvedParentCwd,
7450
+ allowNonZeroExit: true,
7451
+ });
7452
+ if (cloneResult.exitCode !== 0) {
7453
+ return {
7454
+ captured_at: capturedAt,
7455
+ repository,
7456
+ destination_path: destinationPath,
7457
+ resolved_destination_path: resolvedDestinationPath,
7458
+ resolved_parent_cwd: resolvedParentCwd,
7459
+ branch: asString(options.branch) || undefined,
7460
+ depth: options.depth,
7461
+ cloned: false,
7462
+ error_message: this.summarizeGitCommandError(cloneResult, "O Git recusou a operacao de clone."),
7463
+ summary: `O Git recusou o clone para ${path.basename(resolvedDestinationPath) || resolvedDestinationPath}.`,
7464
+ };
7465
+ }
7466
+ const repo = await this.probeGitRepository(resolvedDestinationPath, workspaceContext);
7467
+ const shaProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "HEAD"], {
7468
+ cwd: repo.resolvedCwd,
7469
+ allowNonZeroExit: true,
7470
+ });
7471
+ const commitSha = shaProbe.exitCode === 0 ? (shaProbe.stdout.trim() || undefined) : undefined;
7472
+ return {
7473
+ captured_at: capturedAt,
7474
+ repository,
7475
+ destination_path: destinationPath,
7476
+ resolved_destination_path: resolvedDestinationPath,
7477
+ resolved_parent_cwd: resolvedParentCwd,
7478
+ branch: asString(options.branch) || undefined,
7479
+ depth: options.depth,
7480
+ cloned: true,
7481
+ repo_root: repo.repoRoot || repo.resolvedCwd,
7482
+ current_branch: repo.currentBranch,
7483
+ commit_sha: commitSha,
7484
+ summary: `Clonei o repositorio em ${path.basename(resolvedDestinationPath) || resolvedDestinationPath}.`,
7485
+ };
7486
+ }
7487
+ async gitFetchSnapshot(cwd, options, workspaceContext) {
7488
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7489
+ if (!repo.isRepo) {
7490
+ return {
7491
+ captured_at: repo.capturedAt,
7492
+ cwd,
7493
+ resolved_cwd: repo.resolvedCwd,
7494
+ is_repo: false,
7495
+ remote: options.remote,
7496
+ prune: options.prune === true,
7497
+ tags: options.tags === true,
7498
+ fetched: false,
7499
+ updated_ref_count: 0,
7500
+ updated_refs: [],
7501
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7502
+ };
7503
+ }
7504
+ const fetchArgs = ["fetch"];
7505
+ if (options.prune === true) {
7506
+ fetchArgs.push("--prune");
7507
+ }
7508
+ if (options.tags === true) {
7509
+ fetchArgs.push("--tags");
7510
+ }
7511
+ const fetchRemote = asString(options.remote) || (repo.trackingRef ? repo.trackingRef.split("/", 1)[0] : "") || "";
7512
+ if (fetchRemote) {
7513
+ fetchArgs.push(fetchRemote, `+refs/heads/*:refs/remotes/${fetchRemote}/*`);
7514
+ }
7515
+ const fetchResult = await this.runCommandCapture("/usr/bin/git", fetchArgs, {
7516
+ cwd: repo.resolvedCwd,
7517
+ allowNonZeroExit: true,
7518
+ });
7519
+ const gitOutput = [fetchResult.stdout.trim(), fetchResult.stderr.trim()].filter(Boolean).join("\n").trim();
7520
+ if (fetchResult.exitCode !== 0) {
7521
+ return {
7522
+ captured_at: repo.capturedAt,
7523
+ cwd,
7524
+ resolved_cwd: repo.resolvedCwd,
7525
+ is_repo: true,
7526
+ repo_root: repo.repoRoot,
7527
+ current_branch: repo.currentBranch,
7528
+ remote: asString(options.remote) || undefined,
7529
+ prune: options.prune === true,
7530
+ tags: options.tags === true,
7531
+ fetched: false,
7532
+ tracking_ref: repo.trackingRef,
7533
+ updated_ref_count: 0,
7534
+ updated_refs: [],
7535
+ error_message: clipText(gitOutput || "O Git recusou a operacao de fetch.", 4_000),
7536
+ summary: `O Git recusou o fetch em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7537
+ };
7538
+ }
7539
+ const updatedRefs = this.parseGitFetchUpdatedRefs(gitOutput);
7540
+ const updatedRefCount = updatedRefs.length;
7541
+ return {
7542
+ captured_at: repo.capturedAt,
7543
+ cwd,
7544
+ resolved_cwd: repo.resolvedCwd,
7545
+ is_repo: true,
7546
+ repo_root: repo.repoRoot,
7547
+ current_branch: repo.currentBranch,
7548
+ remote: asString(options.remote) || undefined,
7549
+ prune: options.prune === true,
7550
+ tags: options.tags === true,
7551
+ fetched: true,
7552
+ tracking_ref: repo.trackingRef,
7553
+ updated_ref_count: updatedRefCount,
7554
+ updated_refs: updatedRefs,
7555
+ summary: updatedRefCount > 0
7556
+ ? `Atualizei ${updatedRefCount} ref${updatedRefCount === 1 ? "" : "s"} remota${updatedRefCount === 1 ? "" : "s"} em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`
7557
+ : `Rodei o fetch do repositorio ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7558
+ };
7559
+ }
7560
+ async gitCheckoutSnapshot(cwd, options, workspaceContext) {
7561
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7562
+ if (!repo.isRepo) {
7563
+ return {
7564
+ captured_at: repo.capturedAt,
7565
+ cwd,
7566
+ resolved_cwd: repo.resolvedCwd,
7567
+ is_repo: false,
7568
+ target: options.target,
7569
+ start_point: options.startPoint,
7570
+ create_branch: options.createBranch === true,
7571
+ detach: options.detach === true,
7572
+ switched: false,
7573
+ created_branch: false,
7574
+ detached_head: false,
7575
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7576
+ };
7577
+ }
7578
+ const checkoutArgs = ["checkout"];
7579
+ if (options.detach === true) {
7580
+ checkoutArgs.push("--detach", options.target);
7581
+ }
7582
+ else if (options.createBranch === true) {
7583
+ checkoutArgs.push("-b", options.target);
7584
+ const startPoint = asString(options.startPoint) || "";
7585
+ if (startPoint) {
7586
+ checkoutArgs.push(startPoint);
7587
+ }
7588
+ }
7589
+ else {
7590
+ checkoutArgs.push(options.target);
7591
+ }
7592
+ const checkoutResult = await this.runCommandCapture("/usr/bin/git", checkoutArgs, {
7593
+ cwd: repo.resolvedCwd,
7594
+ allowNonZeroExit: true,
7595
+ });
7596
+ if (checkoutResult.exitCode !== 0) {
7597
+ return {
7598
+ captured_at: repo.capturedAt,
7599
+ cwd,
7600
+ resolved_cwd: repo.resolvedCwd,
7601
+ is_repo: true,
7602
+ repo_root: repo.repoRoot,
7603
+ current_branch: repo.currentBranch,
7604
+ target: options.target,
7605
+ start_point: options.startPoint,
7606
+ create_branch: options.createBranch === true,
7607
+ detach: options.detach === true,
7608
+ switched: false,
7609
+ created_branch: false,
7610
+ detached_head: false,
7611
+ error_message: this.summarizeGitCommandError(checkoutResult, "O Git recusou a operacao de checkout."),
7612
+ summary: `O Git recusou o checkout em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7613
+ };
7614
+ }
7615
+ const updatedRepo = await this.probeGitRepository(repo.resolvedCwd, workspaceContext);
7616
+ const shaProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "HEAD"], {
7617
+ cwd: repo.resolvedCwd,
7618
+ allowNonZeroExit: true,
7619
+ });
7620
+ const headSha = shaProbe.exitCode === 0 ? (shaProbe.stdout.trim() || undefined) : undefined;
7621
+ const detachedHead = updatedRepo.currentBranch === "HEAD" || options.detach === true;
7622
+ const repoLabel = path.basename(updatedRepo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd;
7623
+ const summary = detachedHead
7624
+ ? `Coloquei ${repoLabel} em detached HEAD em ${options.target}.`
7625
+ : options.createBranch === true
7626
+ ? `Criei e troquei para a branch ${options.target} em ${repoLabel}.`
7627
+ : `Troquei para ${options.target} em ${repoLabel}.`;
7628
+ return {
7629
+ captured_at: repo.capturedAt,
7630
+ cwd,
7631
+ resolved_cwd: repo.resolvedCwd,
7632
+ is_repo: true,
7633
+ repo_root: updatedRepo.repoRoot,
7634
+ current_branch: updatedRepo.currentBranch,
7635
+ target: options.target,
7636
+ start_point: options.startPoint,
7637
+ create_branch: options.createBranch === true,
7638
+ detach: options.detach === true,
7639
+ switched: true,
7640
+ created_branch: options.createBranch === true,
7641
+ detached_head: detachedHead,
7642
+ head_sha: headSha,
7643
+ summary,
7644
+ };
7645
+ }
7646
+ async gitRebaseSnapshot(cwd, options, workspaceContext) {
7647
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7648
+ if (!repo.isRepo) {
7649
+ return {
7650
+ captured_at: repo.capturedAt,
7651
+ cwd,
7652
+ resolved_cwd: repo.resolvedCwd,
7653
+ is_repo: false,
7654
+ target: options.target,
7655
+ autostash: options.autostash === true,
7656
+ rebased: false,
7657
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7658
+ };
7659
+ }
7660
+ const rebaseArgs = ["rebase"];
7661
+ if (options.autostash === true) {
7662
+ rebaseArgs.push("--autostash");
7663
+ }
7664
+ rebaseArgs.push(options.target);
7665
+ const rebaseResult = await this.runCommandCapture("/usr/bin/git", rebaseArgs, {
7666
+ cwd: repo.resolvedCwd,
7667
+ allowNonZeroExit: true,
7668
+ });
7669
+ if (rebaseResult.exitCode !== 0) {
7670
+ return {
7671
+ captured_at: repo.capturedAt,
7672
+ cwd,
7673
+ resolved_cwd: repo.resolvedCwd,
7674
+ is_repo: true,
7675
+ repo_root: repo.repoRoot,
7676
+ current_branch: repo.currentBranch,
7677
+ target: options.target,
7678
+ autostash: options.autostash === true,
7679
+ rebased: false,
7680
+ error_message: this.summarizeGitCommandError(rebaseResult, "O Git recusou a operacao de rebase."),
7681
+ summary: `O Git recusou o rebase em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7682
+ };
7683
+ }
7684
+ const updatedRepo = await this.probeGitRepository(repo.resolvedCwd, workspaceContext);
7685
+ const shaProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "HEAD"], {
7686
+ cwd: repo.resolvedCwd,
7687
+ allowNonZeroExit: true,
7688
+ });
7689
+ const headSha = shaProbe.exitCode === 0 ? (shaProbe.stdout.trim() || undefined) : undefined;
7690
+ return {
7691
+ captured_at: repo.capturedAt,
7692
+ cwd,
7693
+ resolved_cwd: repo.resolvedCwd,
7694
+ is_repo: true,
7695
+ repo_root: updatedRepo.repoRoot,
7696
+ current_branch: updatedRepo.currentBranch,
7697
+ target: options.target,
7698
+ autostash: options.autostash === true,
7699
+ rebased: true,
7700
+ head_sha: headSha,
7701
+ summary: `Rebaseei a branch atual sobre ${options.target} em ${path.basename(updatedRepo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7702
+ };
7703
+ }
7704
+ async gitMergeSnapshot(cwd, options, workspaceContext) {
7705
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7706
+ if (!repo.isRepo) {
7707
+ return {
7708
+ captured_at: repo.capturedAt,
7709
+ cwd,
7710
+ resolved_cwd: repo.resolvedCwd,
7711
+ is_repo: false,
7712
+ target: options.target,
7713
+ ff_only: options.ffOnly === true,
7714
+ no_ff: options.noFf === true,
7715
+ merged: false,
7716
+ fast_forward: false,
7717
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7718
+ };
7719
+ }
7720
+ const mergeArgs = ["merge"];
7721
+ if (options.ffOnly === true) {
7722
+ mergeArgs.push("--ff-only");
7723
+ }
7724
+ else if (options.noFf === true) {
7725
+ mergeArgs.push("--no-ff");
7726
+ }
7727
+ const mergeMessage = asString(options.message) || "";
7728
+ if (mergeMessage) {
7729
+ mergeArgs.push("-m", mergeMessage);
7730
+ }
7731
+ mergeArgs.push(options.target);
7732
+ const mergeResult = await this.runCommandCapture("/usr/bin/git", mergeArgs, {
7733
+ cwd: repo.resolvedCwd,
7734
+ allowNonZeroExit: true,
7735
+ });
7736
+ const gitOutput = [mergeResult.stdout.trim(), mergeResult.stderr.trim()].filter(Boolean).join("\n").trim();
7737
+ if (mergeResult.exitCode !== 0) {
7738
+ return {
7739
+ captured_at: repo.capturedAt,
7740
+ cwd,
7741
+ resolved_cwd: repo.resolvedCwd,
7742
+ is_repo: true,
7743
+ repo_root: repo.repoRoot,
7744
+ current_branch: repo.currentBranch,
7745
+ target: options.target,
7746
+ ff_only: options.ffOnly === true,
7747
+ no_ff: options.noFf === true,
7748
+ merged: false,
7749
+ fast_forward: false,
7750
+ error_message: clipText(gitOutput || "O Git recusou a operacao de merge.", 4_000),
7751
+ summary: `O Git recusou o merge em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7752
+ };
7753
+ }
7754
+ const shaProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "HEAD"], {
7755
+ cwd: repo.resolvedCwd,
7756
+ allowNonZeroExit: true,
7757
+ });
7758
+ const commitSha = shaProbe.exitCode === 0 ? (shaProbe.stdout.trim() || undefined) : undefined;
7759
+ const updatedRepo = await this.probeGitRepository(repo.resolvedCwd, workspaceContext);
7760
+ const fastForward = /fast-forward/i.test(gitOutput);
7761
+ return {
7762
+ captured_at: repo.capturedAt,
7763
+ cwd,
7764
+ resolved_cwd: repo.resolvedCwd,
7765
+ is_repo: true,
7766
+ repo_root: updatedRepo.repoRoot,
7767
+ current_branch: updatedRepo.currentBranch,
7768
+ target: options.target,
7769
+ ff_only: options.ffOnly === true,
7770
+ no_ff: options.noFf === true,
7771
+ merged: true,
7772
+ fast_forward: fastForward,
7773
+ commit_sha: commitSha,
7774
+ summary: `Mesclei ${options.target} em ${path.basename(updatedRepo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7775
+ };
7776
+ }
7777
+ async gitTagSnapshot(cwd, options, workspaceContext) {
7778
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7779
+ if (!repo.isRepo) {
7780
+ return {
7781
+ captured_at: repo.capturedAt,
7782
+ cwd,
7783
+ resolved_cwd: repo.resolvedCwd,
7784
+ is_repo: false,
7785
+ name: options.name,
7786
+ target: options.target,
7787
+ annotated: options.annotated === true,
7788
+ message: asString(options.message) || undefined,
7789
+ created: false,
7790
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7791
+ };
7792
+ }
7793
+ const annotated = options.annotated === true || Boolean(asString(options.message));
7794
+ const tagArgs = ["tag"];
7795
+ if (annotated) {
7796
+ tagArgs.push("-a", options.name);
7797
+ const annotation = asString(options.message) || `tag: ${options.name}`;
7798
+ tagArgs.push("-m", annotation);
7799
+ }
7800
+ else {
7801
+ tagArgs.push(options.name);
7802
+ }
7803
+ const tagTarget = asString(options.target) || "";
7804
+ if (tagTarget) {
7805
+ tagArgs.push(tagTarget);
7806
+ }
7807
+ const tagResult = await this.runCommandCapture("/usr/bin/git", tagArgs, {
7808
+ cwd: repo.resolvedCwd,
7809
+ allowNonZeroExit: true,
7810
+ });
7811
+ if (tagResult.exitCode !== 0) {
7812
+ return {
7813
+ captured_at: repo.capturedAt,
7814
+ cwd,
7815
+ resolved_cwd: repo.resolvedCwd,
7816
+ is_repo: true,
7817
+ repo_root: repo.repoRoot,
7818
+ current_branch: repo.currentBranch,
7819
+ name: options.name,
7820
+ target: options.target,
7821
+ annotated,
7822
+ message: asString(options.message) || undefined,
7823
+ created: false,
7824
+ error_message: this.summarizeGitCommandError(tagResult, "O Git recusou a criacao da tag."),
7825
+ summary: `O Git recusou a criacao da tag ${options.name} em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7826
+ };
7827
+ }
7828
+ const tagProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", options.name], {
7829
+ cwd: repo.resolvedCwd,
7830
+ allowNonZeroExit: true,
7831
+ });
7832
+ const tagRef = tagProbe.exitCode === 0 ? (tagProbe.stdout.trim() || undefined) : undefined;
7833
+ return {
7834
+ captured_at: repo.capturedAt,
7835
+ cwd,
7836
+ resolved_cwd: repo.resolvedCwd,
7837
+ is_repo: true,
7838
+ repo_root: repo.repoRoot,
7839
+ current_branch: repo.currentBranch,
7840
+ name: options.name,
7841
+ target: options.target,
7842
+ annotated,
7843
+ message: asString(options.message) || undefined,
7844
+ created: true,
7845
+ tag_ref: tagRef,
7846
+ summary: `Criei a tag ${options.name} em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7847
+ };
7848
+ }
7849
+ parseGitStatusBranchLine(branchLine) {
7850
+ const line = branchLine.replace(/^##\s*/, "").trim();
7851
+ if (!line) {
7852
+ return { ahead: 0, behind: 0 };
7853
+ }
7854
+ const relationMatch = line.match(/^([^.\s]+)(?:\.\.\.([^\s]+))?(?:\s+\[(.+)\])?$/);
7855
+ const currentBranch = relationMatch?.[1]?.trim() || (line.startsWith("HEAD") ? "HEAD" : undefined);
7856
+ const upstreamBranch = relationMatch?.[2]?.trim() || undefined;
7857
+ const relation = relationMatch?.[3] || "";
7858
+ const aheadMatch = relation.match(/ahead\s+(\d+)/i);
7859
+ const behindMatch = relation.match(/behind\s+(\d+)/i);
7860
+ return {
7861
+ currentBranch,
7862
+ upstreamBranch,
7863
+ ahead: aheadMatch ? Number(aheadMatch[1]) : 0,
7864
+ behind: behindMatch ? Number(behindMatch[1]) : 0,
7865
+ };
7866
+ }
7867
+ parseGitStatusEntries(lines) {
7868
+ return lines
7869
+ .map((line) => line.trimEnd())
7870
+ .filter(Boolean)
7871
+ .map((line) => {
7872
+ const stagedStatus = line.slice(0, 1) || " ";
7873
+ const unstagedStatus = line.slice(1, 2) || " ";
7874
+ const payload = line.slice(3).trim();
7875
+ if (!payload) {
7876
+ return null;
7877
+ }
7878
+ const renameMatch = payload.match(/^(.*?)\s+->\s+(.*)$/);
7879
+ return {
7880
+ path: (renameMatch?.[2] || payload).trim(),
7881
+ staged_status: stagedStatus,
7882
+ unstaged_status: unstagedStatus,
7883
+ original_path: renameMatch?.[1]?.trim() || undefined,
7884
+ };
7885
+ })
7886
+ .filter((item) => item !== null);
7887
+ }
7888
+ async gitAddSnapshot(cwd, options, workspaceContext) {
7889
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7890
+ const stagedAll = options.all === true || !(options.paths && options.paths.length > 0);
7891
+ const requestedPaths = uniqueStrings((options.paths || []).map((item) => asString(item)).filter(Boolean));
7892
+ if (!repo.isRepo) {
7893
+ return {
7894
+ captured_at: repo.capturedAt,
7895
+ cwd,
7896
+ resolved_cwd: repo.resolvedCwd,
7897
+ is_repo: false,
7898
+ staged_all: stagedAll,
7899
+ path_count: requestedPaths.length,
7900
+ staged_count: 0,
7901
+ staged_paths: [],
7902
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7903
+ };
7904
+ }
7905
+ const pathspecs = stagedAll
7906
+ ? []
7907
+ : await this.resolveGitPathspecsForRepo(repo.repoRoot || repo.resolvedCwd, repo.resolvedCwd, requestedPaths, workspaceContext);
7908
+ const addArgs = ["add"];
7909
+ if (stagedAll || pathspecs.length === 0) {
7910
+ addArgs.push("--all");
7911
+ }
7912
+ if (pathspecs.length > 0) {
7913
+ addArgs.push("--", ...pathspecs);
7914
+ }
7915
+ const addResult = await this.runCommandCapture("/usr/bin/git", addArgs, {
7916
+ cwd: repo.resolvedCwd,
7917
+ allowNonZeroExit: true,
7918
+ });
7919
+ if (addResult.exitCode !== 0) {
7920
+ return {
7921
+ captured_at: repo.capturedAt,
7922
+ cwd,
7923
+ resolved_cwd: repo.resolvedCwd,
7924
+ is_repo: true,
7925
+ repo_root: repo.repoRoot,
7926
+ current_branch: repo.currentBranch,
7927
+ staged_all: stagedAll,
7928
+ path_count: pathspecs.length,
7929
+ staged_count: 0,
7930
+ staged_paths: [],
7931
+ error_message: this.summarizeGitCommandError(addResult, "O Git recusou a operacao de stage."),
7932
+ summary: `O Git recusou a preparacao do stage em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
7933
+ };
7934
+ }
7935
+ const nameOnlyArgs = ["diff", "--cached", "--name-only"];
7936
+ if (pathspecs.length > 0) {
7937
+ nameOnlyArgs.push("--", ...pathspecs);
7938
+ }
7939
+ const stagedResult = await this.runCommandCapture("/usr/bin/git", nameOnlyArgs, {
7940
+ cwd: repo.resolvedCwd,
7941
+ allowNonZeroExit: true,
7942
+ });
7943
+ const stagedPaths = uniqueStrings(stagedResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
7944
+ const stagedCount = stagedPaths.length;
7945
+ const summary = stagedCount === 0
7946
+ ? stagedAll
7947
+ ? `Nao havia mudancas novas para stage em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`
7948
+ : `Nenhum dos caminhos pedidos gerou stage novo em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`
7949
+ : stagedAll
7950
+ ? `Preparei o stage das mudancas atuais em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`
7951
+ : `Preparei o stage de ${stagedCount} caminho${stagedCount === 1 ? "" : "s"} em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`;
7952
+ return {
7953
+ captured_at: repo.capturedAt,
7954
+ cwd,
7955
+ resolved_cwd: repo.resolvedCwd,
7956
+ is_repo: true,
7957
+ repo_root: repo.repoRoot,
7958
+ current_branch: repo.currentBranch,
7959
+ staged_all: stagedAll,
7960
+ path_count: pathspecs.length,
7961
+ staged_count: stagedCount,
7962
+ staged_paths: stagedPaths,
7963
+ summary,
7964
+ };
7965
+ }
7966
+ async gitCommitSnapshot(cwd, message, allowEmpty, workspaceContext) {
7967
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7968
+ if (!repo.isRepo) {
7969
+ return {
7970
+ captured_at: repo.capturedAt,
7971
+ cwd,
7972
+ resolved_cwd: repo.resolvedCwd,
7973
+ is_repo: false,
7974
+ message,
7975
+ allow_empty: allowEmpty,
7976
+ committed: false,
7977
+ nothing_to_commit: false,
7978
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
7979
+ };
7980
+ }
7981
+ const commitArgs = allowEmpty
7982
+ ? ["commit", "--allow-empty", "-m", message]
7983
+ : ["commit", "-m", message];
7984
+ const commitResult = await this.runCommandCapture("/usr/bin/git", commitArgs, {
7985
+ cwd: repo.resolvedCwd,
7986
+ allowNonZeroExit: true,
7987
+ });
7988
+ if (commitResult.exitCode !== 0) {
7989
+ const gitError = this.summarizeGitCommandError(commitResult, "O Git recusou a operacao de commit.");
7990
+ const nothingToCommit = /nothing to commit|no changes added to commit|working tree clean/i.test(gitError);
7991
+ return {
7992
+ captured_at: repo.capturedAt,
7993
+ cwd,
7994
+ resolved_cwd: repo.resolvedCwd,
7995
+ is_repo: true,
7996
+ repo_root: repo.repoRoot,
7997
+ current_branch: repo.currentBranch,
7998
+ message,
7999
+ allow_empty: allowEmpty,
8000
+ committed: false,
8001
+ nothing_to_commit: nothingToCommit,
8002
+ error_message: nothingToCommit ? undefined : gitError,
8003
+ summary: nothingToCommit
8004
+ ? `Nao havia mudancas staged para criar um commit em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`
8005
+ : `O Git recusou a criacao do commit em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
8006
+ };
8007
+ }
8008
+ const shaProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "HEAD"], {
8009
+ cwd: repo.resolvedCwd,
8010
+ allowNonZeroExit: true,
8011
+ });
8012
+ const commitSha = shaProbe.exitCode === 0 ? (shaProbe.stdout.trim() || undefined) : undefined;
8013
+ return {
8014
+ captured_at: repo.capturedAt,
8015
+ cwd,
8016
+ resolved_cwd: repo.resolvedCwd,
8017
+ is_repo: true,
8018
+ repo_root: repo.repoRoot,
8019
+ current_branch: repo.currentBranch,
8020
+ message,
8021
+ allow_empty: allowEmpty,
8022
+ committed: true,
8023
+ nothing_to_commit: false,
8024
+ commit_sha: commitSha,
8025
+ summary: `Criei um commit local${commitSha ? ` (${commitSha.slice(0, 12)})` : ""} em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
8026
+ };
8027
+ }
8028
+ async gitPushSnapshot(cwd, options, workspaceContext) {
8029
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
8030
+ if (!repo.isRepo) {
8031
+ return {
8032
+ captured_at: repo.capturedAt,
8033
+ cwd,
8034
+ resolved_cwd: repo.resolvedCwd,
8035
+ is_repo: false,
8036
+ remote: options.remote,
8037
+ branch: options.branch,
8038
+ set_upstream: options.setUpstream === true,
8039
+ pushed: false,
8040
+ up_to_date: false,
8041
+ summary: `${repo.resolvedCwd} nao parece ser um repositorio Git.`,
8042
+ };
8043
+ }
8044
+ const branch = asString(options.branch) || repo.currentBranch || undefined;
8045
+ const configuredRemote = asString(options.remote) || "";
8046
+ const effectiveRemote = configuredRemote || (repo.trackingRef ? repo.trackingRef.split("/", 1)[0] : "");
8047
+ const pushArgs = ["push"];
8048
+ if (options.setUpstream === true) {
8049
+ pushArgs.push("--set-upstream");
8050
+ }
8051
+ if (effectiveRemote) {
8052
+ pushArgs.push(effectiveRemote);
8053
+ }
8054
+ if (branch && effectiveRemote) {
8055
+ pushArgs.push(branch);
8056
+ }
8057
+ const pushResult = await this.runCommandCapture("/usr/bin/git", pushArgs, {
8058
+ cwd: repo.resolvedCwd,
8059
+ allowNonZeroExit: true,
8060
+ });
8061
+ const gitOutput = [pushResult.stdout.trim(), pushResult.stderr.trim()].filter(Boolean).join("\n").trim();
8062
+ if (pushResult.exitCode !== 0) {
8063
+ return {
8064
+ captured_at: repo.capturedAt,
8065
+ cwd,
8066
+ resolved_cwd: repo.resolvedCwd,
8067
+ is_repo: true,
8068
+ repo_root: repo.repoRoot,
8069
+ current_branch: repo.currentBranch,
8070
+ remote: effectiveRemote || undefined,
8071
+ branch,
8072
+ set_upstream: options.setUpstream === true,
8073
+ pushed: false,
8074
+ up_to_date: false,
8075
+ tracking_ref: repo.trackingRef,
8076
+ error_message: clipText(gitOutput || "O Git recusou a operacao de push.", 4_000),
8077
+ summary: `O Git recusou o push em ${path.basename(repo.repoRoot || repo.resolvedCwd) || repo.resolvedCwd}.`,
8078
+ };
8079
+ }
8080
+ const trackingProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
8081
+ cwd: repo.resolvedCwd,
8082
+ allowNonZeroExit: true,
8083
+ });
8084
+ const trackingRef = trackingProbe.exitCode === 0
8085
+ ? (trackingProbe.stdout.trim() || undefined)
8086
+ : (repo.trackingRef || (effectiveRemote && branch ? `${effectiveRemote}/${branch}` : undefined));
8087
+ const upToDate = /everything up-to-date/i.test(gitOutput);
8088
+ const remoteLabel = effectiveRemote || (trackingRef ? trackingRef.split("/", 1)[0] : "");
8089
+ const branchLabel = branch || repo.currentBranch || "branch atual";
8090
+ return {
8091
+ captured_at: repo.capturedAt,
8092
+ cwd,
8093
+ resolved_cwd: repo.resolvedCwd,
8094
+ is_repo: true,
8095
+ repo_root: repo.repoRoot,
8096
+ current_branch: repo.currentBranch,
8097
+ remote: remoteLabel || undefined,
8098
+ branch: branch || undefined,
8099
+ set_upstream: options.setUpstream === true,
8100
+ pushed: !upToDate,
8101
+ up_to_date: upToDate,
8102
+ tracking_ref: trackingRef,
8103
+ summary: upToDate
8104
+ ? `A branch ${branchLabel} ja estava sincronizada${remoteLabel ? ` com ${remoteLabel}` : ""}.`
8105
+ : `Enviei a branch ${branchLabel}${remoteLabel ? ` para ${remoteLabel}` : ""}.`,
8106
+ };
8107
+ }
8108
+ async gitStatusSnapshot(cwd, includeUntracked, workspaceContext) {
8109
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd, workspaceContext);
8110
+ const capturedAt = new Date().toISOString();
8111
+ const repoProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "--show-toplevel"], {
8112
+ cwd: resolvedCwd,
8113
+ allowNonZeroExit: true,
8114
+ });
8115
+ if (repoProbe.exitCode !== 0) {
8116
+ return {
8117
+ captured_at: capturedAt,
8118
+ cwd,
8119
+ resolved_cwd: resolvedCwd,
8120
+ is_repo: false,
8121
+ ahead: 0,
8122
+ behind: 0,
8123
+ include_untracked: includeUntracked,
8124
+ is_clean: true,
8125
+ entry_count: 0,
8126
+ staged_count: 0,
8127
+ modified_count: 0,
8128
+ deleted_count: 0,
8129
+ renamed_count: 0,
8130
+ untracked_count: 0,
8131
+ entries: [],
8132
+ summary: `${resolvedCwd} nao parece ser um repositorio Git.`,
8133
+ };
8134
+ }
8135
+ const repoRoot = repoProbe.stdout.trim() || resolvedCwd;
8136
+ const statusResult = await this.runCommandCapture("/usr/bin/git", [
8137
+ "status",
8138
+ "--short",
8139
+ "--branch",
8140
+ "--porcelain=v1",
8141
+ includeUntracked ? "--untracked-files=all" : "--untracked-files=no",
8142
+ ], {
8143
+ cwd: resolvedCwd,
8144
+ allowNonZeroExit: true,
8145
+ });
8146
+ const lines = statusResult.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
8147
+ const branchLine = lines[0]?.startsWith("##") ? lines[0] : "";
8148
+ const entries = this.parseGitStatusEntries(branchLine ? lines.slice(1) : lines);
8149
+ const branchInfo = this.parseGitStatusBranchLine(branchLine);
8150
+ const stagedCount = entries.filter((entry) => ![" ", "?"].includes(entry.staged_status)).length;
8151
+ const modifiedCount = entries.filter((entry) => entry.staged_status === "M" || entry.unstaged_status === "M").length;
8152
+ const deletedCount = entries.filter((entry) => entry.staged_status === "D" || entry.unstaged_status === "D").length;
8153
+ const renamedCount = entries.filter((entry) => entry.staged_status === "R" || entry.unstaged_status === "R").length;
8154
+ const untrackedCount = entries.filter((entry) => entry.staged_status === "?" && entry.unstaged_status === "?").length;
8155
+ const isClean = entries.length === 0;
8156
+ const currentBranch = branchInfo.currentBranch;
8157
+ const summary = isClean
8158
+ ? `O repositorio ${path.basename(repoRoot) || repoRoot} esta limpo${currentBranch ? ` na branch ${currentBranch}` : ""}.`
8159
+ : `O repositorio ${path.basename(repoRoot) || repoRoot}${currentBranch ? ` na branch ${currentBranch}` : ""} tem ${entries.length} arquivo${entries.length === 1 ? "" : "s"} alterado${entries.length === 1 ? "" : "s"} (${stagedCount} staged, ${modifiedCount} modificados, ${untrackedCount} untracked).`;
8160
+ return {
8161
+ captured_at: capturedAt,
8162
+ cwd,
8163
+ resolved_cwd: resolvedCwd,
8164
+ is_repo: true,
8165
+ repo_root: repoRoot,
8166
+ current_branch: currentBranch,
8167
+ upstream_branch: branchInfo.upstreamBranch,
8168
+ ahead: branchInfo.ahead,
8169
+ behind: branchInfo.behind,
8170
+ include_untracked: includeUntracked,
8171
+ is_clean: isClean,
8172
+ entry_count: entries.length,
8173
+ staged_count: stagedCount,
8174
+ modified_count: modifiedCount,
8175
+ deleted_count: deletedCount,
8176
+ renamed_count: renamedCount,
8177
+ untracked_count: untrackedCount,
8178
+ entries,
8179
+ summary,
8180
+ };
8181
+ }
8182
+ async gitDiffSnapshot(cwd, options, workspaceContext) {
8183
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd, workspaceContext);
8184
+ const capturedAt = new Date().toISOString();
8185
+ const repoProbe = await this.runCommandCapture("/usr/bin/git", ["rev-parse", "--show-toplevel"], {
8186
+ cwd: resolvedCwd,
8187
+ allowNonZeroExit: true,
8188
+ });
8189
+ const staged = options.staged === true;
8190
+ const baseRef = asString(options.baseRef) || undefined;
8191
+ if (repoProbe.exitCode !== 0) {
8192
+ return {
8193
+ captured_at: capturedAt,
8194
+ cwd,
8195
+ resolved_cwd: resolvedCwd,
8196
+ is_repo: false,
8197
+ staged,
8198
+ base_ref: baseRef,
8199
+ has_diff: false,
8200
+ file_count: 0,
8201
+ changed_files: [],
8202
+ stat: "",
8203
+ diff: "",
8204
+ diff_char_count: 0,
8205
+ truncated: false,
8206
+ summary: `${resolvedCwd} nao parece ser um repositorio Git.`,
8207
+ };
8208
+ }
8209
+ const repoRoot = repoProbe.stdout.trim() || resolvedCwd;
8210
+ const diffArgs = ["diff", "--no-color"];
8211
+ if (staged) {
8212
+ diffArgs.push("--cached");
8213
+ }
8214
+ if (baseRef) {
8215
+ diffArgs.push(baseRef);
8216
+ }
8217
+ const scopedPaths = (options.paths || [])
8218
+ .map((item) => asString(item))
8219
+ .filter((item) => Boolean(item));
8220
+ const pathArgs = scopedPaths.length > 0 ? ["--", ...scopedPaths] : [];
8221
+ const statResult = await this.runCommandCapture("/usr/bin/git", [...diffArgs, "--stat", ...pathArgs], {
8222
+ cwd: resolvedCwd,
8223
+ allowNonZeroExit: true,
8224
+ });
8225
+ const nameOnlyResult = await this.runCommandCapture("/usr/bin/git", [...diffArgs, "--name-only", ...pathArgs], {
8226
+ cwd: resolvedCwd,
8227
+ allowNonZeroExit: true,
8228
+ });
8229
+ const rawDiffResult = await this.runCommandCapture("/usr/bin/git", [...diffArgs, "--unified=3", ...pathArgs], {
8230
+ cwd: resolvedCwd,
8231
+ allowNonZeroExit: true,
8232
+ });
8233
+ const changedFiles = uniqueStrings(nameOnlyResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
8234
+ const rawDiff = rawDiffResult.stdout.trim();
8235
+ const maxChars = Math.max(1_000, Math.min(options.maxChars || 12_000, 20_000));
8236
+ const clippedDiff = clipText(rawDiff || "(sem diff textual)", maxChars);
8237
+ const summary = changedFiles.length === 0
8238
+ ? `Nao encontrei diff ${staged ? "staged " : ""}pendente em ${path.basename(repoRoot) || repoRoot}.`
8239
+ : `Encontrei diff ${staged ? "staged " : ""}com ${changedFiles.length} arquivo${changedFiles.length === 1 ? "" : "s"} alterado${changedFiles.length === 1 ? "" : "s"} em ${path.basename(repoRoot) || repoRoot}.`;
8240
+ return {
8241
+ captured_at: capturedAt,
8242
+ cwd,
8243
+ resolved_cwd: resolvedCwd,
8244
+ is_repo: true,
8245
+ repo_root: repoRoot,
8246
+ staged,
8247
+ base_ref: baseRef,
8248
+ has_diff: changedFiles.length > 0,
8249
+ file_count: changedFiles.length,
8250
+ changed_files: changedFiles,
8251
+ stat: statResult.stdout.trim(),
8252
+ diff: clippedDiff,
8253
+ diff_char_count: rawDiff.length,
8254
+ truncated: clippedDiff.length < rawDiff.length,
8255
+ summary,
8256
+ };
8257
+ }
8258
+ async runTestsSnapshot(command, cwd, timeoutSeconds = 900, workspaceContext, profile, runtimeManifest) {
8259
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd, workspaceContext);
8260
+ const capturedAt = new Date().toISOString();
8261
+ const timeoutMs = Math.max(5_000, Math.min(timeoutSeconds * 1000, 1_800_000));
8262
+ const ladderStages = !command && (profile === "auto" || !profile)
8263
+ ? this.resolveValidationLadderStages(runtimeManifest, resolvedCwd, workspaceContext)
8264
+ : [];
8265
+ const stagesToRun = ladderStages.length > 0
8266
+ ? ladderStages
8267
+ : [{ stage_id: profile || "explicit_command", title: undefined, profile }];
8268
+ const stageSnapshots = [];
8269
+ for (const stage of stagesToRun) {
8270
+ const startedAt = Date.now();
8271
+ const resolvedTestCommand = await this.resolveRunTestsCommand(command, stage.profile, resolvedCwd, workspaceContext);
8272
+ const result = await this.runCommandCapture("/bin/zsh", ["-lc", resolvedTestCommand.resolvedCommand], {
8273
+ cwd: resolvedCwd,
8274
+ allowNonZeroExit: true,
8275
+ timeoutMs,
8276
+ });
8277
+ const combined = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
8278
+ const output = clipText(combined || "(sem saida)", 16_000);
8279
+ const passed = result.exitCode === 0 && result.timedOut !== true;
8280
+ const durationMs = Math.max(1, Date.now() - startedAt);
8281
+ const summary = result.timedOut
8282
+ ? `A etapa ${stage.stage_id} em ${path.basename(resolvedCwd) || resolvedCwd} excedeu o limite de tempo.`
8283
+ : passed
8284
+ ? `A etapa ${stage.stage_id} em ${path.basename(resolvedCwd) || resolvedCwd} passou com sucesso.`
8285
+ : `A etapa ${stage.stage_id} em ${path.basename(resolvedCwd) || resolvedCwd} falhou com exit code ${result.exitCode}.`;
8286
+ stageSnapshots.push({
8287
+ stage_id: stage.stage_id,
8288
+ title: stage.title,
8289
+ profile: resolvedTestCommand.profile,
8290
+ command: resolvedTestCommand.command,
8291
+ resolved_command: resolvedTestCommand.resolvedCommand,
8292
+ passed,
8293
+ exit_code: result.exitCode,
8294
+ timed_out: result.timedOut,
8295
+ duration_ms: durationMs,
8296
+ output,
8297
+ output_char_count: combined.length,
8298
+ summary,
8299
+ });
8300
+ if (!passed) {
8301
+ break;
8302
+ }
8303
+ if (command) {
8304
+ break;
8305
+ }
8306
+ }
8307
+ const firstStage = stageSnapshots[0];
8308
+ const failedStage = stageSnapshots.find((stage) => !stage.passed);
8309
+ const overallPassed = stageSnapshots.length > 0 && failedStage === undefined;
8310
+ const combinedOutput = clipText(stageSnapshots
8311
+ .map((stage) => `# ${stage.stage_id}\n${stage.output}`)
8312
+ .join("\n\n")
8313
+ || "(sem saida)", 16_000);
8314
+ const totalDurationMs = stageSnapshots.reduce((total, stage) => total + stage.duration_ms, 0);
8315
+ const resolvedCommandLabel = stageSnapshots.map((stage) => stage.resolved_command).join(" && ");
8316
+ const summary = failedStage
8317
+ ? failedStage.summary
8318
+ : `As ${stageSnapshots.length === 1 ? "validacao" : "validacoes"} em ${path.basename(resolvedCwd) || resolvedCwd} passaram com sucesso.`;
8319
+ return {
8320
+ captured_at: capturedAt,
8321
+ cwd,
8322
+ resolved_cwd: resolvedCwd,
8323
+ command: command || (firstStage?.command || resolvedCommandLabel || "validation_ladder"),
8324
+ profile: profile || firstStage?.profile,
8325
+ resolved_command: resolvedCommandLabel || firstStage?.resolved_command || "",
8326
+ passed: overallPassed,
8327
+ exit_code: failedStage?.exit_code ?? firstStage?.exit_code ?? 0,
8328
+ timed_out: failedStage?.timed_out ?? firstStage?.timed_out ?? false,
8329
+ duration_ms: totalDurationMs || firstStage?.duration_ms || 1,
8330
+ output: combinedOutput,
8331
+ output_char_count: combinedOutput.length,
8332
+ summary,
8333
+ selected_ladder_id: ladderStages.length > 0 ? runtimeManifest?.validationLadder?.ladder_id : undefined,
8334
+ stage_count: stageSnapshots.length,
8335
+ failed_stage_id: failedStage?.stage_id,
8336
+ stages: stageSnapshots,
8337
+ };
8338
+ }
8339
+ async runShellCommand(command, cwd, workspaceContext) {
5765
8340
  if (!isSafeShellCommand(command)) {
5766
8341
  throw new Error("Nenhum comando shell foi informado para execucao local.");
5767
8342
  }
5768
- const resolvedCwd = cwd ? expandUserPath(cwd) : process.cwd();
8343
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd || "", workspaceContext);
5769
8344
  const { stdout, stderr } = await this.runCommandCapture("/bin/zsh", ["-lc", command], {
5770
8345
  cwd: resolvedCwd,
5771
8346
  });
@@ -5832,15 +8407,66 @@ if let output = String(data: data, encoding: .utf8) {
5832
8407
  if (action.type === "write_text_file") {
5833
8408
  return `Arquivo de texto escrito em ${action.filename ? `${action.path}/${action.filename}` : action.path}`;
5834
8409
  }
8410
+ if (action.type === "write_json_file") {
8411
+ return `Arquivo JSON escrito em ${action.filename ? `${action.path}/${action.filename}` : action.path}`;
8412
+ }
8413
+ if (action.type === "apply_patch") {
8414
+ return `Patch aplicado em ${action.cwd}`;
8415
+ }
8416
+ if (action.type === "mkdir") {
8417
+ return `Pasta criada em ${action.path}`;
8418
+ }
8419
+ if (action.type === "move_file") {
8420
+ return `Item movido de ${action.source_path} para ${action.destination_path}`;
8421
+ }
8422
+ if (action.type === "git_clone") {
8423
+ return `Repositorio Git clonado em ${action.destination_path}`;
8424
+ }
8425
+ if (action.type === "git_fetch") {
8426
+ return `Fetch do Git executado em ${action.cwd}`;
8427
+ }
8428
+ if (action.type === "git_checkout") {
8429
+ return `Checkout do Git executado em ${action.cwd}`;
8430
+ }
8431
+ if (action.type === "git_rebase") {
8432
+ return `Rebase do Git executado em ${action.cwd}`;
8433
+ }
8434
+ if (action.type === "git_merge") {
8435
+ return `Merge do Git executado em ${action.cwd}`;
8436
+ }
8437
+ if (action.type === "git_tag") {
8438
+ return `Tag ${action.name} criada em ${action.cwd}`;
8439
+ }
8440
+ if (action.type === "git_add") {
8441
+ return `Stage do Git preparado em ${action.cwd}`;
8442
+ }
8443
+ if (action.type === "git_commit") {
8444
+ return `Commit local criado em ${action.cwd}`;
8445
+ }
8446
+ if (action.type === "git_push") {
8447
+ return `Push do Git executado em ${action.cwd}`;
8448
+ }
5835
8449
  if (action.type === "list_files") {
5836
8450
  return `Arquivos listados em ${action.path}`;
5837
8451
  }
8452
+ if (action.type === "delete_file") {
8453
+ return `Arquivo apagado em ${action.path}`;
8454
+ }
5838
8455
  if (action.type === "count_files") {
5839
8456
  return `Arquivos contados em ${action.path}`;
5840
8457
  }
5841
8458
  if (action.type === "system_status") {
5842
8459
  return "Status do macOS coletado";
5843
8460
  }
8461
+ if (action.type === "git_status") {
8462
+ return `Estado do Git lido em ${action.cwd}`;
8463
+ }
8464
+ if (action.type === "git_diff") {
8465
+ return `Diff do Git lido em ${action.cwd}`;
8466
+ }
8467
+ if (action.type === "run_tests") {
8468
+ return `Validacao ${action.profile || "local"} executada em ${action.cwd}`;
8469
+ }
5844
8470
  if (action.type === "run_shell") {
5845
8471
  return `Comando ${action.command} executado no macOS`;
5846
8472
  }
@@ -5875,9 +8501,31 @@ if let output = String(data: data, encoding: .utf8) {
5875
8501
  });
5876
8502
  this.activeChild = child;
5877
8503
  try {
5878
- const { stdout, stderr } = await new Promise((resolve, reject) => {
8504
+ const { stdout, stderr, exitCode, timedOut } = await new Promise((resolve, reject) => {
5879
8505
  let stdout = "";
5880
8506
  let stderr = "";
8507
+ let timedOut = false;
8508
+ let settled = false;
8509
+ const timeoutMs = options?.timeoutMs;
8510
+ const timer = timeoutMs && timeoutMs > 0
8511
+ ? setTimeout(() => {
8512
+ timedOut = true;
8513
+ child.kill("SIGTERM");
8514
+ setTimeout(() => {
8515
+ try {
8516
+ child.kill("SIGKILL");
8517
+ }
8518
+ catch {
8519
+ // Process already exited.
8520
+ }
8521
+ }, 1000).unref();
8522
+ }, timeoutMs)
8523
+ : null;
8524
+ const clearTimer = () => {
8525
+ if (timer) {
8526
+ clearTimeout(timer);
8527
+ }
8528
+ };
5881
8529
  if (options?.stdin !== undefined) {
5882
8530
  child.stdin.write(options.stdin);
5883
8531
  child.stdin.end();
@@ -5892,21 +8540,36 @@ if let output = String(data: data, encoding: .utf8) {
5892
8540
  stderr += String(chunk);
5893
8541
  });
5894
8542
  child.on("error", (error) => {
8543
+ if (settled) {
8544
+ return;
8545
+ }
8546
+ settled = true;
8547
+ clearTimer();
5895
8548
  reject(error);
5896
8549
  });
5897
8550
  child.on("close", (code) => {
5898
- if (code === 0) {
5899
- resolve({ stdout, stderr });
8551
+ if (settled) {
8552
+ return;
8553
+ }
8554
+ settled = true;
8555
+ clearTimer();
8556
+ const normalizedExitCode = typeof code === "number" ? code : (timedOut ? 124 : 1);
8557
+ if (timedOut && options?.allowNonZeroExit !== true) {
8558
+ reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`));
8559
+ return;
8560
+ }
8561
+ if (normalizedExitCode === 0 || options?.allowNonZeroExit === true) {
8562
+ resolve({ stdout, stderr, exitCode: normalizedExitCode, timedOut });
5900
8563
  return;
5901
8564
  }
5902
- reject(new Error(`${command} ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit code ${code ?? "unknown"}`}`));
8565
+ reject(new Error(`${command} ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit code ${normalizedExitCode}`}`));
5903
8566
  });
5904
8567
  });
5905
8568
  const stderrText = stderr.trim();
5906
8569
  if (stderrText) {
5907
8570
  console.warn(`[otto-bridge] ${command} stderr=${stderrText}`);
5908
8571
  }
5909
- return { stdout, stderr };
8572
+ return { stdout, stderr, exitCode, timedOut };
5910
8573
  }
5911
8574
  catch (error) {
5912
8575
  const detail = error instanceof Error ? error.message : String(error);