@leg3ndy/otto-bridge 0.9.1 → 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, stat } 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) {
@@ -702,6 +702,17 @@ function clipTextPreview(value, maxLength) {
702
702
  }
703
703
  return `${value.slice(0, maxLength)}\n\n[conteudo truncado: mostrando ${maxLength} de ${value.length} caracteres. Peca um trecho mais especifico se quiser continuar.]`;
704
704
  }
705
+ function sanitizeFileName(value, fallback = "otto-note.txt") {
706
+ const normalized = String(value || "")
707
+ .normalize("NFD")
708
+ .replace(/\p{Diacritic}/gu, "")
709
+ .replace(/[\/\\?%*:|"<>]/g, "-")
710
+ .replace(/\s+/g, " ")
711
+ .trim();
712
+ const collapsed = normalized.replace(/\.+/g, ".").replace(/^\.+/, "").replace(/\.+$/, "");
713
+ const candidate = collapsed || fallback;
714
+ return path.extname(candidate) ? candidate : `${candidate}.txt`;
715
+ }
705
716
  function chunkTextForTransport(value, chunkSize) {
706
717
  const normalized = String(value || "");
707
718
  if (!normalized) {
@@ -1009,6 +1020,269 @@ function parseStructuredActions(job) {
1009
1020
  }
1010
1021
  continue;
1011
1022
  }
1023
+ if (type === "write_text_file" || type === "write_file" || type === "save_text_file" || type === "save_file") {
1024
+ const targetPath = asString(action.path)
1025
+ || asString(action.destination)
1026
+ || asString(action.file_path)
1027
+ || asString(action.target)
1028
+ || asString(action.directory)
1029
+ || asString(action.folder)
1030
+ || (asString(action.filename) ? "~/Desktop" : "");
1031
+ const text = asString(action.text) || asString(action.content) || asString(action.body) || asString(action.value);
1032
+ const source = asString(action.source) || asString(action.input_source);
1033
+ if (targetPath && (text || source)) {
1034
+ actions.push({
1035
+ type: "write_text_file",
1036
+ path: targetPath,
1037
+ text: text || undefined,
1038
+ content: asString(action.content) || undefined,
1039
+ body: asString(action.body) || undefined,
1040
+ source: source || undefined,
1041
+ filename: asString(action.filename) || asString(action.file_name) || asString(action.name) || asString(action.title) || undefined,
1042
+ append: action.append === true,
1043
+ });
1044
+ }
1045
+ continue;
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
+ }
1012
1286
  if (type === "list_files" || type === "ls") {
1013
1287
  const filePath = asString(action.path) || "~";
1014
1288
  const limit = typeof action.limit === "number" ? Math.max(1, Math.min(5_000, action.limit)) : undefined;
@@ -1045,6 +1319,13 @@ function parseStructuredActions(job) {
1045
1319
  }
1046
1320
  continue;
1047
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
+ }
1048
1329
  if (type === "set_volume" || type === "volume") {
1049
1330
  const rawLevel = Number(action.level);
1050
1331
  if (Number.isFinite(rawLevel)) {
@@ -1214,6 +1495,46 @@ function extractActions(job) {
1214
1495
  }
1215
1496
  return deriveActionsFromText(job);
1216
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
+ }
1217
1538
  export class NativeMacOSJobExecutor {
1218
1539
  bridgeConfig;
1219
1540
  cancelledJobs = new Set();
@@ -1221,6 +1542,7 @@ export class NativeMacOSJobExecutor {
1221
1542
  lastActiveApp = null;
1222
1543
  lastVisualTargetDescription = null;
1223
1544
  lastVisualTargetApp = null;
1545
+ lastReadFrontmostPage = null;
1224
1546
  lastSatisfiedSpotifyDescription = null;
1225
1547
  lastSatisfiedSpotifyConfirmedPlaying = false;
1226
1548
  lastSatisfiedSpotifyAt = 0;
@@ -1237,6 +1559,14 @@ export class NativeMacOSJobExecutor {
1237
1559
  throw new Error("The native-macos executor only runs on macOS");
1238
1560
  }
1239
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
+ });
1240
1570
  if (actions.length === 0) {
1241
1571
  throw new Error("Otto Bridge native-macos could not derive a supported local action from this request");
1242
1572
  }
@@ -1249,39 +1579,307 @@ export class NativeMacOSJobExecutor {
1249
1579
  const decision = await reporter.confirmRequired(confirmation.message, {
1250
1580
  actions,
1251
1581
  executor: "native-macos",
1582
+ }, {
1583
+ stepId: runtimeManifest.approvalStepId,
1252
1584
  });
1253
1585
  if (decision.action !== "approve") {
1254
1586
  throw new JobCancelledError(job.job_id);
1255
1587
  }
1256
1588
  }
1257
- try {
1258
- const completionNotes = [];
1259
- const artifacts = [];
1260
- const resultPayload = {
1261
- 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,
1262
1769
  actions,
1263
1770
  artifacts,
1264
- action_summaries: completionNotes,
1265
- };
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 {
1266
1805
  for (let index = 0; index < actions.length; index += 1) {
1267
1806
  this.assertNotCancelled(job.job_id);
1268
1807
  const action = actions[index];
1269
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
+ };
1270
1828
  if (action.type === "open_app") {
1271
- await reporter.progress(progressPercent, `Abrindo ${action.app} no macOS`);
1829
+ await reportActionProgress(`Abrindo ${action.app} no macOS`);
1272
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
+ });
1273
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
+ });
1274
1846
  continue;
1275
1847
  }
1276
1848
  if (action.type === "focus_app") {
1277
- await reporter.progress(progressPercent, `Trazendo ${action.app} para frente`);
1849
+ await reportActionProgress(`Trazendo ${action.app} para frente`);
1278
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
+ });
1279
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
+ });
1280
1866
  continue;
1281
1867
  }
1282
1868
  if (action.type === "press_shortcut") {
1283
- await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
1869
+ await reportActionProgress(`Enviando atalho ${action.shortcut}`);
1284
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
+ });
1285
1883
  if (action.shortcut.startsWith("media_")) {
1286
1884
  const mediaSummaryMap = {
1287
1885
  media_next: "Acionei o comando de próxima mídia no macOS.",
@@ -1303,14 +1901,23 @@ export class NativeMacOSJobExecutor {
1303
1901
  continue;
1304
1902
  }
1305
1903
  if (action.type === "create_note") {
1306
- await reporter.progress(progressPercent, "Criando nota no Notes");
1904
+ await reportActionProgress("Criando nota no Notes");
1307
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
+ });
1308
1915
  completionNotes.push(`Nota criada no Notes: ${noteTitle}`);
1309
1916
  continue;
1310
1917
  }
1311
1918
  if (action.type === "type_text") {
1312
1919
  const typingApp = this.lastActiveApp || await this.getFrontmostAppName();
1313
- await reporter.progress(progressPercent, `Digitando texto em ${typingApp || "app ativo"}`);
1920
+ await reportActionProgress(`Digitando texto em ${typingApp || "app ativo"}`);
1314
1921
  const typed = await this.guidedTypeText(action.text, typingApp || undefined);
1315
1922
  if (!typed.ok) {
1316
1923
  throw new Error(typed.reason || "Nao consegui digitar o texto no app ativo.");
@@ -1322,6 +1929,15 @@ export class NativeMacOSJobExecutor {
1322
1929
  attempts: typed.attempts,
1323
1930
  text_preview: clipText(action.text, 180),
1324
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
+ });
1325
1941
  this.lastVisualTargetDescription = null;
1326
1942
  this.lastVisualTargetApp = null;
1327
1943
  completionNotes.push(typed.verified
@@ -1330,14 +1946,16 @@ export class NativeMacOSJobExecutor {
1330
1946
  continue;
1331
1947
  }
1332
1948
  if (action.type === "take_screenshot") {
1333
- await reporter.progress(progressPercent, "Capturando screenshot do Mac");
1949
+ await reportActionProgress("Capturando screenshot do Mac");
1334
1950
  const screenshotPath = await this.takeScreenshot(action.path);
1335
1951
  const uploadable = await this.buildUploadableImage(screenshotPath);
1952
+ const expectedArtifactId = runtimeExpectedArtifactIdForActionIndex(runtimeManifest, index, "screenshot");
1336
1953
  const screenshotArtifact = await this.uploadArtifactForJob(job.job_id, uploadable.path, {
1337
1954
  kind: "screenshot",
1338
1955
  mimeTypeOverride: uploadable.mimeType,
1339
1956
  fileNameOverride: uploadable.filename,
1340
1957
  metadata: {
1958
+ expected_artifact_id: expectedArtifactId || undefined,
1341
1959
  visible_in_chat: true,
1342
1960
  width: uploadable.dimensions?.width || undefined,
1343
1961
  height: uploadable.dimensions?.height || undefined,
@@ -1357,10 +1975,14 @@ export class NativeMacOSJobExecutor {
1357
1975
  continue;
1358
1976
  }
1359
1977
  if (action.type === "read_frontmost_page") {
1360
- await reporter.progress(progressPercent, `Lendo a pagina ativa em ${action.app || "Safari"}`);
1978
+ await reportActionProgress(`Lendo a pagina ativa em ${action.app || "Safari"}`);
1361
1979
  const page = await this.readFrontmostPage(action.app || "Safari");
1980
+ this.lastReadFrontmostPage = {
1981
+ app: action.app || "Safari",
1982
+ ...page,
1983
+ };
1362
1984
  if (!page.text && this.bridgeConfig?.apiBaseUrl && this.bridgeConfig?.deviceToken) {
1363
- 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");
1364
1986
  const screenshotPath = await this.takeScreenshot();
1365
1987
  const uploadable = await this.buildUploadableImage(screenshotPath);
1366
1988
  const artifact = await this.uploadArtifactForJob(job.job_id, uploadable.path, {
@@ -1389,52 +2011,424 @@ export class NativeMacOSJobExecutor {
1389
2011
  }
1390
2012
  }
1391
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
+ });
1392
2020
  completionNotes.push(`Li a pagina ${page.title || page.url || "ativa"} no navegador.`);
1393
2021
  continue;
1394
2022
  }
1395
2023
  if (action.type === "browser_context") {
1396
- await reporter.progress(progressPercent, "Lendo o contexto do navegador ativo");
2024
+ await reportActionProgress("Lendo o contexto do navegador ativo");
1397
2025
  const browserContext = await this.collectBrowserContext(action.app, action.include_text === true, action.include_tabs === true);
1398
2026
  resultPayload.browser_context = browserContext;
1399
2027
  resultPayload.summary = browserContext.summary;
2028
+ appendActionArtifact("browser_context", {
2029
+ summary: browserContext.summary,
2030
+ title: browserContext.title || undefined,
2031
+ path: browserContext.url || undefined,
2032
+ });
1400
2033
  completionNotes.push(browserContext.summary);
1401
2034
  continue;
1402
2035
  }
1403
2036
  if (action.type === "app_status") {
1404
- await reporter.progress(progressPercent, "Lendo os apps ativos do Mac");
2037
+ await reportActionProgress("Lendo os apps ativos do Mac");
1405
2038
  const appStatus = await this.collectAppStatus(action.app, action.include_frontmost === true, action.include_running_apps === true, action.include_top_processes === true);
1406
2039
  resultPayload.app_status = appStatus;
1407
2040
  resultPayload.summary = appStatus.summary;
2041
+ appendActionArtifact("app_status", {
2042
+ summary: appStatus.summary,
2043
+ app: action.app || appStatus.frontmost_app || undefined,
2044
+ });
1408
2045
  completionNotes.push(appStatus.summary);
1409
2046
  continue;
1410
2047
  }
1411
2048
  if (action.type === "filesystem_inspect") {
1412
- await reporter.progress(progressPercent, `Inspecionando ${action.path}`);
1413
- 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);
1414
2051
  resultPayload.filesystem = filesystem;
1415
2052
  resultPayload.summary = filesystem.summary;
2053
+ appendActionArtifact("filesystem_snapshot", {
2054
+ summary: filesystem.summary,
2055
+ path: filesystem.resolved_path,
2056
+ });
1416
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
+ });
1417
2063
  continue;
1418
2064
  }
1419
2065
  if (action.type === "read_file") {
1420
- await reporter.progress(progressPercent, `Lendo ${action.path}`);
1421
- 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);
1422
2068
  resultPayload.read_file = fileContent;
1423
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
+ });
1424
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
+ });
2082
+ continue;
2083
+ }
2084
+ if (action.type === "trash_path") {
2085
+ await reportActionProgress(`Movendo ${action.path} para a Lixeira`);
2086
+ const trashed = await this.movePathToTrashSnapshot(action.path, workspaceContext);
2087
+ resultPayload.trash_path = trashed;
2088
+ resultPayload.summary = trashed.summary;
2089
+ appendActionArtifact("trash_receipt", {
2090
+ summary: trashed.summary,
2091
+ path: trashed.trashed_path,
2092
+ filename: trashed.name,
2093
+ });
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
+ });
2118
+ continue;
2119
+ }
2120
+ if (action.type === "write_text_file") {
2121
+ const targetLabel = action.filename ? `${action.path}/${action.filename}` : action.path;
2122
+ await reportActionProgress(`Escrevendo arquivo de texto em ${targetLabel}`);
2123
+ const resolvedContent = this.resolveWriteTextFileContent(action);
2124
+ if (!resolvedContent) {
2125
+ throw new Error("Nenhum texto foi informado para gravar no arquivo local.");
2126
+ }
2127
+ const written = await this.writeTextFileSnapshot(action.path, resolvedContent.content, action.filename, action.append === true, resolvedContent.source, workspaceContext);
2128
+ resultPayload.write_text_file = written;
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
+ });
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
+ });
1425
2410
  continue;
1426
2411
  }
1427
2412
  if (action.type === "list_files") {
1428
- await reporter.progress(progressPercent, `Listando arquivos em ${action.path}`);
1429
- 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);
1430
2415
  resultPayload.file_listing = listing;
1431
2416
  resultPayload.summary = listing.summary;
2417
+ appendActionArtifact("file_listing", {
2418
+ summary: listing.summary,
2419
+ path: listing.resolved_path,
2420
+ });
1432
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
+ });
1433
2427
  continue;
1434
2428
  }
1435
2429
  if (action.type === "count_files") {
1436
- await reporter.progress(progressPercent, `Contando arquivos em ${action.path}`);
1437
- 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);
1438
2432
  completionNotes.push(`Encontrei ${counted.total} arquivo${counted.total === 1 ? "" : "s"} ${counted.extensionsLabel} em ${counted.path}.`);
1439
2433
  resultPayload.file_count = {
1440
2434
  total: counted.total,
@@ -1442,36 +2436,145 @@ export class NativeMacOSJobExecutor {
1442
2436
  extensions: counted.extensions,
1443
2437
  recursive: counted.recursive,
1444
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
+ });
1445
2448
  continue;
1446
2449
  }
1447
2450
  if (action.type === "system_status") {
1448
- await reporter.progress(progressPercent, "Lendo CPU, memoria, disco e bateria do Mac");
2451
+ await reportActionProgress("Lendo CPU, memoria, disco e bateria do Mac");
1449
2452
  const systemStatus = await this.collectSystemStatus(action.sections, action.include_top_processes === true);
1450
2453
  resultPayload.system_status = systemStatus;
1451
2454
  resultPayload.summary = systemStatus.summary;
2455
+ appendActionArtifact("system_status", {
2456
+ summary: systemStatus.summary,
2457
+ });
1452
2458
  completionNotes.push(systemStatus.summary);
1453
2459
  continue;
1454
2460
  }
1455
- if (action.type === "run_shell") {
1456
- await reporter.progress(progressPercent, `Rodando comando local: ${action.command}`);
1457
- const shellOutput = await this.runShellCommand(action.command, action.cwd);
1458
- 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
+ });
1459
2477
  continue;
1460
2478
  }
1461
- if (action.type === "set_volume") {
1462
- await reporter.progress(progressPercent, `Ajustando volume para ${action.level}%`);
1463
- await this.setVolume(action.level);
1464
- completionNotes.push(`Volume ajustado para ${action.level}% no macOS.`);
1465
- continue;
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
+ });
2500
+ continue;
2501
+ }
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;
1466
2569
  }
1467
2570
  if (action.type === "scroll_view") {
1468
2571
  const scrollApp = action.app || this.lastActiveApp || await this.getFrontmostAppName();
1469
2572
  if (scrollApp) {
1470
- await reporter.progress(progressPercent, `Trazendo ${scrollApp} para frente antes de rolar a tela`);
2573
+ await reportActionProgress(`Trazendo ${scrollApp} para frente antes de rolar a tela`);
1471
2574
  await this.focusApp(scrollApp);
1472
2575
  }
1473
2576
  const directionLabel = action.direction === "up" ? "cima" : "baixo";
1474
- await reporter.progress(progressPercent, `Rolando a tela para ${directionLabel}`);
2577
+ await reportActionProgress(`Rolando a tela para ${directionLabel}`);
1475
2578
  await this.scrollView(action.direction, action.amount, action.steps);
1476
2579
  resultPayload.last_scroll = {
1477
2580
  direction: action.direction,
@@ -1479,11 +2582,15 @@ export class NativeMacOSJobExecutor {
1479
2582
  steps: action.steps || 1,
1480
2583
  app: scrollApp || null,
1481
2584
  };
2585
+ appendActionArtifact("viewport_change", {
2586
+ summary: `Rolei a tela para ${directionLabel} no macOS.`,
2587
+ app: scrollApp || null,
2588
+ });
1482
2589
  completionNotes.push(`Rolei a tela para ${directionLabel} no macOS.`);
1483
2590
  continue;
1484
2591
  }
1485
2592
  if (action.type === "whatsapp_send_message") {
1486
- await reporter.progress(progressPercent, `Abrindo a conversa do WhatsApp com ${action.contact}`);
2593
+ await reportActionProgress(`Abrindo a conversa do WhatsApp com ${action.contact}`);
1487
2594
  await this.ensureWhatsAppWebReady();
1488
2595
  const selected = await this.selectWhatsAppConversation(action.contact);
1489
2596
  if (!selected) {
@@ -1493,7 +2600,7 @@ export class NativeMacOSJobExecutor {
1493
2600
  messages: [],
1494
2601
  summary: "",
1495
2602
  }));
1496
- await reporter.progress(progressPercent, `Enviando a mensagem para ${action.contact} no WhatsApp`);
2603
+ await reportActionProgress(`Enviando a mensagem para ${action.contact} no WhatsApp`);
1497
2604
  await this.sendWhatsAppMessage(action.text);
1498
2605
  await delay(900);
1499
2606
  const afterSend = await this.readWhatsAppVisibleConversation(action.contact, Math.max(12, beforeSend.messages.length + 4)).catch(() => null);
@@ -1504,17 +2611,22 @@ export class NativeMacOSJobExecutor {
1504
2611
  messages: afterSend?.messages || [],
1505
2612
  summary: afterSend?.summary || "",
1506
2613
  };
2614
+ appendActionArtifact("message_delivery", {
2615
+ summary: `Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`,
2616
+ contact: action.contact,
2617
+ });
1507
2618
  const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1508
2619
  if (!verification.ok) {
1509
2620
  resultPayload.summary = verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`;
1510
- 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);
1511
2623
  return;
1512
2624
  }
1513
2625
  completionNotes.push(`Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`);
1514
2626
  continue;
1515
2627
  }
1516
2628
  if (action.type === "whatsapp_read_chat") {
1517
- await reporter.progress(progressPercent, `Abrindo a conversa do WhatsApp com ${action.contact}`);
2629
+ await reportActionProgress(`Abrindo a conversa do WhatsApp com ${action.contact}`);
1518
2630
  await this.ensureWhatsAppWebReady();
1519
2631
  const selected = await this.selectWhatsAppConversation(action.contact);
1520
2632
  if (!selected) {
@@ -1527,17 +2639,21 @@ export class NativeMacOSJobExecutor {
1527
2639
  contact: action.contact,
1528
2640
  messages: chat.messages,
1529
2641
  };
2642
+ appendActionArtifact("message_snapshot", {
2643
+ summary: `Mensagens visiveis no WhatsApp com ${action.contact}.`,
2644
+ contact: action.contact,
2645
+ });
1530
2646
  completionNotes.push(`Mensagens visiveis no WhatsApp com ${action.contact}:\n${chat.summary}`);
1531
2647
  continue;
1532
2648
  }
1533
2649
  if (action.type === "click_visual_target") {
1534
2650
  const browserApp = await this.resolveLikelyBrowserApp(action.app);
1535
2651
  if (browserApp) {
1536
- await reporter.progress(progressPercent, `Trazendo ${browserApp} para frente antes do clique`);
2652
+ await reportActionProgress(`Trazendo ${browserApp} para frente antes do clique`);
1537
2653
  await this.focusApp(browserApp);
1538
2654
  }
1539
2655
  else if (action.app) {
1540
- await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do clique`);
2656
+ await reportActionProgress(`Trazendo ${action.app} para frente antes do clique`);
1541
2657
  await this.focusApp(action.app);
1542
2658
  }
1543
2659
  const targetDescriptions = isSpotifySafariDomOnlyStep(action.description)
@@ -1553,7 +2669,7 @@ export class NativeMacOSJobExecutor {
1553
2669
  const isSpotifySafariStep = browserApp === "Safari" && isSpotifySafariDomOnlyStep(targetDescription);
1554
2670
  const verificationPrompt = isSpotifySafariStep ? undefined : action.verification_prompt;
1555
2671
  if (isSpotifySafariStep) {
1556
- 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`);
1557
2673
  const spotifyDomResult = await this.executeSpotifySafariDomStep(targetDescription, initialBrowserState);
1558
2674
  if (spotifyDomResult.ok) {
1559
2675
  this.rememberSatisfiedSpotifyStep(targetDescription, !!spotifyDomResult.confirmedPlaying);
@@ -1579,7 +2695,7 @@ export class NativeMacOSJobExecutor {
1579
2695
  }
1580
2696
  const nativeMediaTransport = extractNativeMediaTransportCommand(targetDescription);
1581
2697
  if (nativeMediaTransport) {
1582
- 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}`);
1583
2699
  try {
1584
2700
  await this.triggerMacOSMediaTransport(nativeMediaTransport);
1585
2701
  let validated = false;
@@ -1590,7 +2706,7 @@ export class NativeMacOSJobExecutor {
1590
2706
  validationReason = browserValidation.reason;
1591
2707
  }
1592
2708
  if (!validated && verificationPrompt) {
1593
- 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");
1594
2710
  if (verification.unavailable) {
1595
2711
  lastFailureReason = verification.reason;
1596
2712
  break;
@@ -1611,14 +2727,14 @@ export class NativeMacOSJobExecutor {
1611
2727
  break;
1612
2728
  }
1613
2729
  lastFailureReason = validationReason || `O controle de mídia nativo do macOS nao confirmou ${targetDescription}.`;
1614
- 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");
1615
2731
  }
1616
2732
  catch (error) {
1617
2733
  lastFailureReason = error instanceof Error ? error.message : String(error);
1618
2734
  }
1619
2735
  }
1620
2736
  if (browserApp === "Safari") {
1621
- await reporter.progress(progressPercent, `Tentando localizar ${targetDescription} diretamente no Safari`);
2737
+ await reportActionProgress(`Tentando localizar ${targetDescription} diretamente no Safari`);
1622
2738
  const domClick = await this.trySafariDomClick(targetDescription);
1623
2739
  if (domClick?.clicked) {
1624
2740
  let validated = false;
@@ -1629,7 +2745,7 @@ export class NativeMacOSJobExecutor {
1629
2745
  validationReason = browserValidation.reason;
1630
2746
  }
1631
2747
  if (!validated && verificationPrompt) {
1632
- 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");
1633
2749
  if (verification.unavailable) {
1634
2750
  lastFailureReason = verification.reason;
1635
2751
  break;
@@ -1663,7 +2779,7 @@ export class NativeMacOSJobExecutor {
1663
2779
  const visualBeforeState = browserApp
1664
2780
  ? await this.captureBrowserPageState(browserApp).catch(() => initialBrowserState)
1665
2781
  : initialBrowserState;
1666
- await reporter.progress(progressPercent, `Capturando a tela para localizar ${targetDescription}`);
2782
+ await reportActionProgress(`Capturando a tela para localizar ${targetDescription}`);
1667
2783
  let screenshotPath = await this.takeScreenshot();
1668
2784
  const ocrClick = await this.tryLocalOcrClick(screenshotPath, targetDescription);
1669
2785
  if (ocrClick.clicked) {
@@ -1675,7 +2791,7 @@ export class NativeMacOSJobExecutor {
1675
2791
  validationReason = browserValidation.reason;
1676
2792
  }
1677
2793
  if (!validated && verificationPrompt) {
1678
- 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");
1679
2795
  if (verification.unavailable) {
1680
2796
  lastFailureReason = verification.reason;
1681
2797
  break;
@@ -1704,7 +2820,7 @@ export class NativeMacOSJobExecutor {
1704
2820
  break;
1705
2821
  }
1706
2822
  lastFailureReason = validationReason || `O clique por OCR local em ${targetDescription} nao teve efeito confirmavel.`;
1707
- 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");
1708
2824
  screenshotPath = await this.takeScreenshot();
1709
2825
  }
1710
2826
  else if (ocrClick.reason) {
@@ -1745,7 +2861,7 @@ export class NativeMacOSJobExecutor {
1745
2861
  }
1746
2862
  continue;
1747
2863
  }
1748
- await reporter.progress(progressPercent, `Clicando em ${targetDescription}`);
2864
+ await reportActionProgress(`Clicando em ${targetDescription}`);
1749
2865
  const scaledX = width > 0 && originalWidth > 0 ? (location.x / width) * originalWidth : location.x;
1750
2866
  const scaledY = height > 0 && originalHeight > 0 ? (location.y / height) * originalHeight : location.y;
1751
2867
  await this.clickPoint(scaledX, scaledY);
@@ -1756,7 +2872,7 @@ export class NativeMacOSJobExecutor {
1756
2872
  strategy: "visual_locator",
1757
2873
  };
1758
2874
  if (verificationPrompt) {
1759
- 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");
1760
2876
  if (verification.unavailable) {
1761
2877
  lastFailureReason = verification.reason;
1762
2878
  break;
@@ -1782,19 +2898,22 @@ export class NativeMacOSJobExecutor {
1782
2898
  if (!clickSucceeded) {
1783
2899
  throw new Error(lastFailureReason || `Nao consegui concluir o clique visual para ${action.description}.`);
1784
2900
  }
2901
+ appendActionArtifact("interaction_result", {
2902
+ summary: `Localizei e cliquei em ${action.description}.`,
2903
+ });
1785
2904
  continue;
1786
2905
  }
1787
2906
  if (action.type === "drag_visual_target") {
1788
2907
  const dragApp = await this.resolveLikelyBrowserApp(action.app);
1789
2908
  if (dragApp) {
1790
- await reporter.progress(progressPercent, `Trazendo ${dragApp} para frente antes do arraste`);
2909
+ await reportActionProgress(`Trazendo ${dragApp} para frente antes do arraste`);
1791
2910
  await this.focusApp(dragApp);
1792
2911
  }
1793
2912
  else if (action.app) {
1794
- await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do arraste`);
2913
+ await reportActionProgress(`Trazendo ${action.app} para frente antes do arraste`);
1795
2914
  await this.focusApp(action.app);
1796
2915
  }
1797
- 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}`);
1798
2917
  const screenshotPath = await this.takeScreenshot();
1799
2918
  const sourcePoint = await this.resolveVisualTargetPoint(job.job_id, screenshotPath, action.source_description, artifacts, "drag_source");
1800
2919
  const targetPoint = await this.resolveVisualTargetPoint(job.job_id, screenshotPath, action.target_description, artifacts, "drag_target");
@@ -1804,18 +2923,29 @@ export class NativeMacOSJobExecutor {
1804
2923
  if (!targetPoint) {
1805
2924
  throw new Error(`Nao consegui localizar ${action.target_description} com confianca suficiente para concluir o arraste.`);
1806
2925
  }
1807
- await reporter.progress(progressPercent, `Arrastando ${action.source_description} para ${action.target_description}`);
2926
+ await reportActionProgress(`Arrastando ${action.source_description} para ${action.target_description}`);
1808
2927
  await this.dragPoint(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
1809
2928
  resultPayload.last_drag = {
1810
2929
  source: sourcePoint,
1811
2930
  target: targetPoint,
1812
2931
  };
2932
+ appendActionArtifact("interaction_result", {
2933
+ summary: `Arrastei ${action.source_description} para ${action.target_description}.`,
2934
+ });
1813
2935
  completionNotes.push(`Arrastei ${action.source_description} para ${action.target_description}.`);
1814
2936
  continue;
1815
2937
  }
1816
- await reporter.progress(progressPercent, `Abrindo ${action.url}${action.app ? ` em ${action.app}` : ""}`);
2938
+ await reportActionProgress(`Abrindo ${action.url}${action.app ? ` em ${action.app}` : ""}`);
1817
2939
  await this.openUrl(action.url, action.app);
1818
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
+ });
1819
2949
  completionNotes.push(`${humanizeUrl(action.url)} foi aberto${action.app ? ` em ${action.app}` : ""}.`);
1820
2950
  }
1821
2951
  const summary = completionNotes.length > 0
@@ -1823,8 +2953,47 @@ export class NativeMacOSJobExecutor {
1823
2953
  : (actions.length === 1
1824
2954
  ? this.describeAction(actions[0])
1825
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
+ }
1826
2968
  resultPayload.summary = summary;
1827
- 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;
1828
2997
  }
1829
2998
  finally {
1830
2999
  this.cancelledJobs.delete(job.job_id);
@@ -2315,7 +3484,10 @@ return appNames as text
2315
3484
  status.summary = this.buildAppStatusSummary(status);
2316
3485
  return status;
2317
3486
  }
2318
- async resolveFilesystemInspectPath(targetPath) {
3487
+ async resolveFilesystemInspectPath(targetPath, workspaceContext) {
3488
+ if (workspaceContext) {
3489
+ return assertPathInsideWorkspace(workspaceContext, targetPath);
3490
+ }
2319
3491
  const expanded = expandUserPath(targetPath);
2320
3492
  try {
2321
3493
  await stat(expanded);
@@ -2342,8 +3514,8 @@ return appNames as text
2342
3514
  return 0;
2343
3515
  }
2344
3516
  }
2345
- async inspectFilesystemPath(targetPath, includeChildren = true, includePreview = false, limit = 8) {
2346
- 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);
2347
3519
  const entryStat = await stat(resolved);
2348
3520
  const itemName = path.basename(resolved) || resolved;
2349
3521
  if (entryStat.isDirectory()) {
@@ -4606,25 +5778,68 @@ return {
4606
5778
  if (targetApp !== "Safari") {
4607
5779
  throw new Error("Leitura de pagina frontmost esta disponivel apenas para Safari no momento.");
4608
5780
  }
4609
- const script = `
4610
- tell application "Safari"
4611
- activate
4612
- if (count of windows) = 0 then error "Safari nao possui janelas abertas."
4613
- delay 1
4614
- set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const isYouTubeMusic=location.hostname.includes('music.youtube.com');const isSpotify=location.hostname.includes('open.spotify.com');let playerButton=null;let playerTitle='';let playerState='';if(isYouTubeMusic){playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';}else if(isSpotify){const visible=(node)=>{if(!(node instanceof Element))return false;const rect=node.getBoundingClientRect();if(rect.width<4||rect.height<4)return false;const style=window.getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none'||Number(style.opacity||'1')===0)return false;return rect.bottom>=0&&rect.right>=0&&rect.top<=window.innerHeight&&rect.left<=window.innerWidth;};const spotifyTitleCandidates=Array.from(document.querySelectorAll(\"[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']\")).filter((node)=>visible(node)).map((node)=>({node,text:((node&&node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>entry.text).sort((left,right)=>{const leftBottomBias=(left.rect.top>=window.innerHeight*0.72?200:0)+(left.rect.left<=window.innerWidth*0.45?120:0)+left.rect.top;const rightBottomBias=(right.rect.top>=window.innerHeight*0.72?200:0)+(right.rect.left<=window.innerWidth*0.45?120:0)+right.rect.top;return rightBottomBias-leftBottomBias;});playerTitle=(spotifyTitleCandidates[0]&&spotifyTitleCandidates[0].text)||'';playerButton=Array.from(document.querySelectorAll(\"footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]\")).filter((node)=>visible(node)).map((node)=>({node,label:((node.getAttribute('aria-label')||node.getAttribute('title')||node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>/play|pause|tocar|pausar|reproduzir/i.test(entry.label)).sort((left,right)=>{const leftScore=(left.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((left.rect.left+left.rect.width/2)-(window.innerWidth/2)));const rightScore=(right.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((right.rect.left+right.rect.width/2)-(window.innerWidth/2)));return rightScore-leftScore;})[0]?.node||null;playerState=(playerButton&&((playerButton.getAttribute('aria-label')||playerButton.getAttribute('title')||playerButton.textContent)||'').trim())||'';}return JSON.stringify({title,url,text,playerTitle,playerState});})();"
4615
- set pageJson to do JavaScript jsCode in current tab of front window
4616
- end tell
4617
- return pageJson
4618
- `;
4619
5781
  try {
4620
- const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
4621
- const parsed = JSON.parse(stdout.trim() || "{}");
5782
+ const page = await this.runSafariJsonScript(`
5783
+ const title = document.title || "";
5784
+ const url = location.href || "";
5785
+ const text = ((document.body && document.body.innerText) || "").trim().slice(0, 12000);
5786
+ const isYouTubeMusic = location.hostname.includes("music.youtube.com");
5787
+ const isSpotify = location.hostname.includes("open.spotify.com");
5788
+ let playerButton = null;
5789
+ let playerTitle = "";
5790
+ let playerState = "";
5791
+
5792
+ if (isYouTubeMusic) {
5793
+ playerButton = document.querySelector("ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button");
5794
+ playerTitle = (Array.from(document.querySelectorAll("ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]"))
5795
+ .map((node) => ((node && node.textContent) || "").trim())
5796
+ .find(Boolean)) || "";
5797
+ playerState = (playerButton && ((playerButton.getAttribute("title") || playerButton.getAttribute("aria-label") || playerButton.textContent) || "").trim()) || "";
5798
+ } else if (isSpotify) {
5799
+ const visible = (node) => {
5800
+ if (!(node instanceof Element)) return false;
5801
+ const rect = node.getBoundingClientRect();
5802
+ if (rect.width < 4 || rect.height < 4) return false;
5803
+ const style = window.getComputedStyle(node);
5804
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
5805
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
5806
+ };
5807
+ const spotifyTitleCandidates = Array.from(document.querySelectorAll("[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']"))
5808
+ .filter((node) => visible(node))
5809
+ .map((node) => ({ node, text: ((node && node.textContent) || "").trim(), rect: node.getBoundingClientRect() }))
5810
+ .filter((entry) => entry.text)
5811
+ .sort((left, right) => {
5812
+ const leftBottomBias = (left.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + (left.rect.left <= window.innerWidth * 0.45 ? 120 : 0) + left.rect.top;
5813
+ const rightBottomBias = (right.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + (right.rect.left <= window.innerWidth * 0.45 ? 120 : 0) + right.rect.top;
5814
+ return rightBottomBias - leftBottomBias;
5815
+ });
5816
+ playerTitle = (spotifyTitleCandidates[0] && spotifyTitleCandidates[0].text) || "";
5817
+ playerButton = Array.from(document.querySelectorAll("footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]"))
5818
+ .filter((node) => visible(node))
5819
+ .map((node) => ({ node, label: ((node.getAttribute("aria-label") || node.getAttribute("title") || node.textContent) || "").trim(), rect: node.getBoundingClientRect() }))
5820
+ .filter((entry) => /play|pause|tocar|pausar|reproduzir/i.test(entry.label))
5821
+ .sort((left, right) => {
5822
+ const leftScore = (left.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + Math.max(0, 200 - Math.abs((left.rect.left + left.rect.width / 2) - (window.innerWidth / 2)));
5823
+ const rightScore = (right.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + Math.max(0, 200 - Math.abs((right.rect.left + right.rect.width / 2) - (window.innerWidth / 2)));
5824
+ return rightScore - leftScore;
5825
+ })[0]?.node || null;
5826
+ playerState = (playerButton && ((playerButton.getAttribute("aria-label") || playerButton.getAttribute("title") || playerButton.textContent) || "").trim()) || "";
5827
+ }
5828
+
5829
+ return {
5830
+ title,
5831
+ url,
5832
+ text,
5833
+ playerTitle,
5834
+ playerState,
5835
+ };
5836
+ `, {}, { activate: true });
4622
5837
  return {
4623
- title: asString(parsed.title) || "",
4624
- url: asString(parsed.url) || "",
4625
- text: asString(parsed.text) || "",
4626
- playerTitle: asString(parsed.playerTitle) || "",
4627
- playerState: asString(parsed.playerState) || "",
5838
+ title: page.title || "",
5839
+ url: page.url || "",
5840
+ text: page.text || "",
5841
+ playerTitle: page.playerTitle || "",
5842
+ playerState: page.playerState || "",
4628
5843
  };
4629
5844
  }
4630
5845
  catch (error) {
@@ -5036,8 +6251,8 @@ if let output = String(data: data, encoding: .utf8) {
5036
6251
  }
5037
6252
  return clipTextPreview(loaded.content || "(arquivo vazio)", maxChars);
5038
6253
  }
5039
- async readLocalFileSnapshot(filePath, chunkSizeChars = 4000) {
5040
- const resolved = await this.resolveReadableFilePath(filePath);
6254
+ async readLocalFileSnapshot(filePath, chunkSizeChars = 4000, workspaceContext) {
6255
+ const resolved = await this.resolveReadableFilePath(filePath, workspaceContext);
5041
6256
  const entryStat = await stat(resolved);
5042
6257
  const loaded = await this.loadReadableFileContent(resolved);
5043
6258
  const fileName = path.basename(resolved) || resolved;
@@ -5075,8 +6290,11 @@ if let output = String(data: data, encoding: .utf8) {
5075
6290
  summary: `Li ${filePath} por completo (${content.length} caracteres em ${chunks.length} bloco${chunks.length === 1 ? "" : "s"}).`,
5076
6291
  };
5077
6292
  }
5078
- async resolveReadableFilePath(filePath) {
5079
- const resolved = expandUserPath(filePath);
6293
+ async resolveTrashTargetPath(targetPath, workspaceContext) {
6294
+ if (workspaceContext) {
6295
+ return assertPathInsideWorkspace(workspaceContext, targetPath);
6296
+ }
6297
+ const resolved = expandUserPath(targetPath);
5080
6298
  try {
5081
6299
  await stat(resolved);
5082
6300
  return resolved;
@@ -5084,8 +6302,8 @@ if let output = String(data: data, encoding: .utf8) {
5084
6302
  catch {
5085
6303
  // Continue into heuristic search below.
5086
6304
  }
5087
- const filename = path.basename(resolved).trim();
5088
- if (!filename || filename === "." || filename === path.sep) {
6305
+ const targetName = path.basename(resolved).trim();
6306
+ if (!targetName || targetName === "." || targetName === path.sep) {
5089
6307
  return resolved;
5090
6308
  }
5091
6309
  const homeDir = os.homedir();
@@ -5097,11 +6315,14 @@ if let output = String(data: data, encoding: .utf8) {
5097
6315
  path.join(homeDir, "Documents"),
5098
6316
  homeDir,
5099
6317
  ]);
5100
- const found = await this.findFileByName(filename, preferredRoots);
6318
+ const found = await this.findPathByName(targetName, preferredRoots);
5101
6319
  return found || resolved;
5102
6320
  }
5103
- async findFileByName(filename, roots) {
5104
- const target = filename.toLowerCase();
6321
+ async findPathByName(targetName, roots) {
6322
+ const normalizedTarget = normalizeText(targetName).replace(/\s+/g, " ").trim();
6323
+ if (!normalizedTarget) {
6324
+ return null;
6325
+ }
5105
6326
  for (const root of roots) {
5106
6327
  let rootStat;
5107
6328
  try {
@@ -5127,16 +6348,17 @@ if let output = String(data: data, encoding: .utf8) {
5127
6348
  }
5128
6349
  for (const entry of entries) {
5129
6350
  const entryPath = path.join(current, entry.name);
6351
+ const normalizedEntryName = normalizeText(entry.name).replace(/\s+/g, " ").trim();
6352
+ if (normalizedEntryName === normalizedTarget) {
6353
+ return entryPath;
6354
+ }
5130
6355
  if (entry.isDirectory()) {
5131
6356
  if (!FILE_SEARCH_SKIP_DIRS.has(entry.name)) {
5132
6357
  queue.push(entryPath);
5133
6358
  }
5134
6359
  continue;
5135
6360
  }
5136
- if (!entry.isFile()) {
5137
- continue;
5138
- }
5139
- if (entry.name.toLowerCase() === target) {
6361
+ if (entry.isFile() && entry.name.toLowerCase() === targetName.toLowerCase()) {
5140
6362
  return entryPath;
5141
6363
  }
5142
6364
  }
@@ -5144,337 +6366,1981 @@ if let output = String(data: data, encoding: .utf8) {
5144
6366
  }
5145
6367
  return null;
5146
6368
  }
5147
- async listLocalFilesSnapshot(directoryPath, limit) {
5148
- const resolved = expandUserPath(directoryPath);
5149
- const allEntries = await readdir(resolved, { withFileTypes: true });
5150
- const sortedEntries = allEntries.sort((left, right) => {
5151
- if (left.isDirectory() !== right.isDirectory()) {
5152
- return left.isDirectory() ? -1 : 1;
5153
- }
5154
- return left.name.localeCompare(right.name);
5155
- });
5156
- const effectiveLimit = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(Math.round(limit), 5_000)) : null;
5157
- const selectedEntries = effectiveLimit ? sortedEntries.slice(0, effectiveLimit) : sortedEntries;
5158
- const items = await Promise.all(selectedEntries.map(async (entry) => {
5159
- const entryPath = path.join(resolved, entry.name);
5160
- let sizeBytes;
5161
- let modifiedAt;
6369
+ async resolveUniqueTrashDestination(targetPath) {
6370
+ const ext = path.extname(targetPath);
6371
+ const stem = ext ? targetPath.slice(0, -ext.length) : targetPath;
6372
+ let attempt = 0;
6373
+ let candidate = targetPath;
6374
+ while (true) {
5162
6375
  try {
5163
- const entryStat = await stat(entryPath);
5164
- if (!entry.isDirectory()) {
5165
- sizeBytes = entryStat.size;
5166
- }
5167
- modifiedAt = entryStat.mtime.toISOString();
6376
+ await stat(candidate);
6377
+ attempt += 1;
6378
+ candidate = `${stem} ${attempt + 1}${ext}`;
5168
6379
  }
5169
6380
  catch {
5170
- // Ignore stat failures and return the visible entry metadata we have.
6381
+ return candidate;
5171
6382
  }
5172
- return {
5173
- name: entry.name,
5174
- path: entryPath,
5175
- kind: entry.isDirectory() ? "directory" : (entry.isFile() ? "file" : "other"),
5176
- size_bytes: sizeBytes,
5177
- modified_at: modifiedAt,
5178
- };
5179
- }));
5180
- const summary = items.length === 0
5181
- ? `A pasta ${directoryPath} esta vazia.`
5182
- : effectiveLimit && allEntries.length > items.length
5183
- ? `Listei ${items.length} itens visiveis em ${directoryPath} agora. A pasta tem ${allEntries.length} itens no total.`
5184
- : `Listei ${items.length} itens de ${directoryPath} por completo.`;
6383
+ }
6384
+ }
6385
+ async movePathToTrashSnapshot(targetPath, workspaceContext) {
6386
+ const resolved = await this.resolveTrashTargetPath(targetPath, workspaceContext);
6387
+ const entryStat = await stat(resolved);
6388
+ const trashDir = path.join(os.homedir(), ".Trash");
6389
+ await mkdir(trashDir, { recursive: true });
6390
+ const name = path.basename(resolved) || resolved;
6391
+ const trashedPath = await this.resolveUniqueTrashDestination(path.join(trashDir, name));
6392
+ await rename(resolved, trashedPath);
6393
+ const kind = entryStat.isDirectory()
6394
+ ? "directory"
6395
+ : entryStat.isFile()
6396
+ ? "file"
6397
+ : "other";
5185
6398
  return {
5186
6399
  captured_at: new Date().toISOString(),
5187
- path: directoryPath,
6400
+ path: targetPath,
5188
6401
  resolved_path: resolved,
5189
- name: path.basename(resolved) || resolved,
5190
- item_count: items.length,
5191
- total_item_count: allEntries.length,
5192
- limit_applied: effectiveLimit || undefined,
5193
- entries: items,
5194
- summary,
6402
+ trashed_path: trashedPath,
6403
+ name,
6404
+ kind,
6405
+ size_bytes: entryStat.size,
6406
+ modified_at: entryStat.mtime.toISOString(),
6407
+ summary: `${kind === "directory" ? "Mandei a pasta" : kind === "file" ? "Mandei o arquivo" : "Mandei o item"} ${name} para a Lixeira.`,
5195
6408
  };
5196
6409
  }
5197
- async countLocalFiles(directoryPath, extensions, recursive = true) {
5198
- const resolved = expandUserPath(directoryPath);
5199
- const normalizedExtensions = Array.from(new Set((extensions || [])
5200
- .map((extension) => String(extension || "").trim().toLowerCase().replace(/^\./, ""))
5201
- .filter(Boolean)));
5202
- const queue = [resolved];
5203
- let total = 0;
5204
- while (queue.length > 0) {
5205
- const current = queue.shift();
5206
- if (!current)
5207
- continue;
5208
- let entries;
6410
+ async resolveWritableTextFilePath(targetPath, filename, workspaceContext) {
6411
+ const expanded = workspaceContext
6412
+ ? expandUserPathLike(targetPath, workspaceContext.defaultCwd)
6413
+ : expandUserPath(targetPath);
6414
+ const requestedFilename = filename ? sanitizeFileName(filename) : null;
6415
+ if (requestedFilename) {
5209
6416
  try {
5210
- entries = await readdir(current, { withFileTypes: true });
6417
+ const existingStat = await stat(expanded);
6418
+ if (existingStat.isDirectory()) {
6419
+ const resolvedDirectoryPath = path.join(expanded, requestedFilename);
6420
+ return workspaceContext
6421
+ ? assertPathInsideWorkspace(workspaceContext, resolvedDirectoryPath)
6422
+ : resolvedDirectoryPath;
6423
+ }
5211
6424
  }
5212
6425
  catch {
5213
- continue;
6426
+ // Continue below and treat the target as a direct file path.
5214
6427
  }
5215
- for (const entry of entries) {
5216
- const entryPath = path.join(current, entry.name);
5217
- if (entry.isDirectory()) {
5218
- if (recursive) {
5219
- queue.push(entryPath);
5220
- }
5221
- continue;
5222
- }
5223
- if (!entry.isFile()) {
5224
- continue;
5225
- }
5226
- if (normalizedExtensions.length > 0) {
5227
- const entryExtension = path.extname(entry.name).toLowerCase().replace(/^\./, "");
5228
- if (!normalizedExtensions.includes(entryExtension)) {
5229
- continue;
5230
- }
5231
- }
5232
- total += 1;
6428
+ if (String(targetPath || "").trimEnd().endsWith(path.sep)) {
6429
+ const resolvedDirectoryPath = path.join(expanded, requestedFilename);
6430
+ return workspaceContext
6431
+ ? assertPathInsideWorkspace(workspaceContext, resolvedDirectoryPath)
6432
+ : resolvedDirectoryPath;
5233
6433
  }
5234
6434
  }
5235
- const extensionsLabel = normalizedExtensions.length > 0
5236
- ? normalizedExtensions.map((extension) => `.${extension}`).join(", ")
5237
- : "do tipo solicitado";
6435
+ try {
6436
+ const existingStat = await stat(expanded);
6437
+ if (existingStat.isDirectory()) {
6438
+ const fallbackDirectoryPath = path.join(expanded, sanitizeFileName("otto-note.txt"));
6439
+ return workspaceContext
6440
+ ? assertPathInsideWorkspace(workspaceContext, fallbackDirectoryPath)
6441
+ : fallbackDirectoryPath;
6442
+ }
6443
+ }
6444
+ catch {
6445
+ // Continue below and treat the target as a direct file path.
6446
+ }
6447
+ if (String(targetPath || "").trimEnd().endsWith(path.sep)) {
6448
+ const fallbackDirectoryPath = path.join(expanded, sanitizeFileName("otto-note.txt"));
6449
+ return workspaceContext
6450
+ ? assertPathInsideWorkspace(workspaceContext, fallbackDirectoryPath)
6451
+ : fallbackDirectoryPath;
6452
+ }
6453
+ return workspaceContext
6454
+ ? assertPathInsideWorkspace(workspaceContext, expanded)
6455
+ : expanded;
6456
+ }
6457
+ async writeTextFileSnapshot(targetPath, text, filename, append = false, source, workspaceContext) {
6458
+ const resolved = await this.resolveWritableTextFilePath(targetPath, filename, workspaceContext);
6459
+ const parentDir = path.dirname(resolved);
6460
+ await mkdir(parentDir, { recursive: true });
6461
+ await writeFile(resolved, text, {
6462
+ encoding: "utf8",
6463
+ flag: append ? "a" : "w",
6464
+ });
6465
+ const entryStat = await stat(resolved);
6466
+ const name = path.basename(resolved) || resolved;
6467
+ const preview = clipTextPreview(String(text || "") || "(arquivo vazio)", 240);
5238
6468
  return {
5239
- total,
5240
- path: directoryPath,
5241
- extensions: normalizedExtensions,
5242
- recursive,
5243
- extensionsLabel,
6469
+ captured_at: new Date().toISOString(),
6470
+ path: targetPath,
6471
+ resolved_path: resolved,
6472
+ name,
6473
+ mime_type: "text/plain; charset=utf-8",
6474
+ size_bytes: entryStat.size,
6475
+ modified_at: entryStat.mtime.toISOString(),
6476
+ append,
6477
+ content_char_count: String(text || "").length,
6478
+ content_preview: preview,
6479
+ source: source || undefined,
6480
+ summary: `${append ? "Atualizei" : "Escrevi"} ${String(text || "").length} caractere${String(text || "").length === 1 ? "" : "s"} em ${resolved}.`,
5244
6481
  };
5245
6482
  }
5246
- snapshotCpuTimes() {
5247
- const cpus = os.cpus();
5248
- let idle = 0;
5249
- let total = 0;
5250
- for (const cpu of cpus) {
5251
- idle += cpu.times.idle;
5252
- total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
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.");
5253
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;
5254
6494
  return {
5255
- idle,
5256
- total,
5257
- model: cpus[0]?.model || "Apple Silicon",
5258
- logicalCores: cpus.length || 0,
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}.`,
5259
6506
  };
5260
6507
  }
5261
- async sampleCpuStatus() {
5262
- const start = this.snapshotCpuTimes();
5263
- await delay(320);
5264
- const end = this.snapshotCpuTimes();
5265
- const totalDelta = Math.max(1, end.total - start.total);
5266
- const idleDelta = Math.max(0, end.idle - start.idle);
5267
- const idlePercent = roundMetric((idleDelta / totalDelta) * 100, 1);
5268
- const usagePercent = roundMetric(Math.max(0, 100 - idlePercent), 1);
5269
- const [load1m, load5m, load15m] = os.loadavg();
5270
- return {
5271
- usage_percent: usagePercent,
5272
- idle_percent: idlePercent,
5273
- logical_cores: end.logicalCores,
5274
- model: end.model,
5275
- load_average_1m: roundMetric(load1m, 2),
5276
- load_average_5m: roundMetric(load5m, 2),
5277
- load_average_15m: roundMetric(load15m, 2),
5278
- };
6508
+ resolvePatchTargetPath(targetPath, resolvedCwd, workspaceContext) {
6509
+ return workspaceContext
6510
+ ? assertPathInsideWorkspace(workspaceContext, targetPath, { baseCwd: resolvedCwd })
6511
+ : expandUserPathLike(targetPath, resolvedCwd);
5279
6512
  }
5280
- async readMemoryStatus() {
5281
- const totalBytes = os.totalmem();
5282
- const freeBytes = os.freemem();
5283
- const usedBytes = Math.max(0, totalBytes - freeBytes);
5284
- let compressedBytes = 0;
5285
- let swapUsedBytes = 0;
5286
- try {
5287
- const { stdout } = await this.runCommandCapture("/usr/bin/vm_stat", []);
5288
- const pageSizeMatch = stdout.match(/page size of (\d+) bytes/i);
5289
- const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 16384;
5290
- const compressedMatch = stdout.match(/Pages occupied by compressor:\s+([0-9.]+)/i);
5291
- if (compressedMatch) {
5292
- compressedBytes = Math.round(Number(compressedMatch[1]) * pageSize);
5293
- }
6513
+ countTextLines(value) {
6514
+ const normalized = String(value || "").replace(/\r\n/g, "\n");
6515
+ if (!normalized) {
6516
+ return 0;
5294
6517
  }
5295
- catch {
5296
- compressedBytes = 0;
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
+ };
5297
6543
  }
5298
- try {
5299
- const { stdout } = await this.runCommandCapture("/usr/sbin/sysctl", ["vm.swapusage"]);
5300
- const usedMatch = stdout.match(/used = ([0-9.]+)([BKMGTP]+)/i);
5301
- if (usedMatch) {
5302
- swapUsedBytes = parseScaledBytes(usedMatch[1], usedMatch[2]);
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;
5303
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
+ };
5304
6565
  }
5305
- catch {
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
+ }
6758
+ resolveWriteTextFileContent(action) {
6759
+ const explicitText = [action.text, action.content, action.body]
6760
+ .map((value) => String(value || "").trim())
6761
+ .find(Boolean);
6762
+ if (explicitText) {
6763
+ return { content: explicitText };
6764
+ }
6765
+ const source = String(action.source || "").trim().toLowerCase();
6766
+ if (source === "last_page_text" || source === "last_page_summary") {
6767
+ const pageText = String(this.lastReadFrontmostPage?.text || "").trim();
6768
+ if (pageText) {
6769
+ return { content: pageText, source };
6770
+ }
6771
+ }
6772
+ return null;
6773
+ }
6774
+ async resolveReadableFilePath(filePath, workspaceContext) {
6775
+ if (workspaceContext) {
6776
+ return assertPathInsideWorkspace(workspaceContext, filePath);
6777
+ }
6778
+ const resolved = expandUserPath(filePath);
6779
+ try {
6780
+ await stat(resolved);
6781
+ return resolved;
6782
+ }
6783
+ catch {
6784
+ // Continue into heuristic search below.
6785
+ }
6786
+ const filename = path.basename(resolved).trim();
6787
+ if (!filename || filename === "." || filename === path.sep) {
6788
+ return resolved;
6789
+ }
6790
+ const homeDir = os.homedir();
6791
+ const requestedDir = path.dirname(resolved);
6792
+ const preferredRoots = uniqueStrings([
6793
+ requestedDir && requestedDir !== homeDir ? requestedDir : null,
6794
+ path.join(homeDir, "Downloads"),
6795
+ path.join(homeDir, "Desktop"),
6796
+ path.join(homeDir, "Documents"),
6797
+ homeDir,
6798
+ ]);
6799
+ const found = await this.findFileByName(filename, preferredRoots);
6800
+ return found || resolved;
6801
+ }
6802
+ async findFileByName(filename, roots) {
6803
+ const target = filename.toLowerCase();
6804
+ for (const root of roots) {
6805
+ let rootStat;
6806
+ try {
6807
+ rootStat = await stat(root);
6808
+ }
6809
+ catch {
6810
+ continue;
6811
+ }
6812
+ if (!rootStat.isDirectory()) {
6813
+ continue;
6814
+ }
6815
+ const queue = [root];
6816
+ while (queue.length > 0) {
6817
+ const current = queue.shift();
6818
+ if (!current)
6819
+ continue;
6820
+ let entries;
6821
+ try {
6822
+ entries = await readdir(current, { withFileTypes: true });
6823
+ }
6824
+ catch {
6825
+ continue;
6826
+ }
6827
+ for (const entry of entries) {
6828
+ const entryPath = path.join(current, entry.name);
6829
+ if (entry.isDirectory()) {
6830
+ if (!FILE_SEARCH_SKIP_DIRS.has(entry.name)) {
6831
+ queue.push(entryPath);
6832
+ }
6833
+ continue;
6834
+ }
6835
+ if (!entry.isFile()) {
6836
+ continue;
6837
+ }
6838
+ if (entry.name.toLowerCase() === target) {
6839
+ return entryPath;
6840
+ }
6841
+ }
6842
+ }
6843
+ }
6844
+ return null;
6845
+ }
6846
+ async listLocalFilesSnapshot(directoryPath, limit, workspaceContext) {
6847
+ const resolved = workspaceContext
6848
+ ? assertPathInsideWorkspace(workspaceContext, directoryPath)
6849
+ : expandUserPath(directoryPath);
6850
+ const allEntries = await readdir(resolved, { withFileTypes: true });
6851
+ const sortedEntries = allEntries.sort((left, right) => {
6852
+ if (left.isDirectory() !== right.isDirectory()) {
6853
+ return left.isDirectory() ? -1 : 1;
6854
+ }
6855
+ return left.name.localeCompare(right.name);
6856
+ });
6857
+ const effectiveLimit = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(Math.round(limit), 5_000)) : null;
6858
+ const selectedEntries = effectiveLimit ? sortedEntries.slice(0, effectiveLimit) : sortedEntries;
6859
+ const items = await Promise.all(selectedEntries.map(async (entry) => {
6860
+ const entryPath = path.join(resolved, entry.name);
6861
+ let sizeBytes;
6862
+ let modifiedAt;
6863
+ try {
6864
+ const entryStat = await stat(entryPath);
6865
+ if (!entry.isDirectory()) {
6866
+ sizeBytes = entryStat.size;
6867
+ }
6868
+ modifiedAt = entryStat.mtime.toISOString();
6869
+ }
6870
+ catch {
6871
+ // Ignore stat failures and return the visible entry metadata we have.
6872
+ }
6873
+ return {
6874
+ name: entry.name,
6875
+ path: entryPath,
6876
+ kind: entry.isDirectory() ? "directory" : (entry.isFile() ? "file" : "other"),
6877
+ size_bytes: sizeBytes,
6878
+ modified_at: modifiedAt,
6879
+ };
6880
+ }));
6881
+ const summary = items.length === 0
6882
+ ? `A pasta ${directoryPath} esta vazia.`
6883
+ : effectiveLimit && allEntries.length > items.length
6884
+ ? `Listei ${items.length} itens visiveis em ${directoryPath} agora. A pasta tem ${allEntries.length} itens no total.`
6885
+ : `Listei ${items.length} itens de ${directoryPath} por completo.`;
6886
+ return {
6887
+ captured_at: new Date().toISOString(),
6888
+ path: directoryPath,
6889
+ resolved_path: resolved,
6890
+ name: path.basename(resolved) || resolved,
6891
+ item_count: items.length,
6892
+ total_item_count: allEntries.length,
6893
+ limit_applied: effectiveLimit || undefined,
6894
+ entries: items,
6895
+ summary,
6896
+ };
6897
+ }
6898
+ async countLocalFiles(directoryPath, extensions, recursive = true, workspaceContext) {
6899
+ const resolved = workspaceContext
6900
+ ? assertPathInsideWorkspace(workspaceContext, directoryPath)
6901
+ : expandUserPath(directoryPath);
6902
+ const normalizedExtensions = Array.from(new Set((extensions || [])
6903
+ .map((extension) => String(extension || "").trim().toLowerCase().replace(/^\./, ""))
6904
+ .filter(Boolean)));
6905
+ const queue = [resolved];
6906
+ let total = 0;
6907
+ while (queue.length > 0) {
6908
+ const current = queue.shift();
6909
+ if (!current)
6910
+ continue;
6911
+ let entries;
6912
+ try {
6913
+ entries = await readdir(current, { withFileTypes: true });
6914
+ }
6915
+ catch {
6916
+ continue;
6917
+ }
6918
+ for (const entry of entries) {
6919
+ const entryPath = path.join(current, entry.name);
6920
+ if (entry.isDirectory()) {
6921
+ if (recursive) {
6922
+ queue.push(entryPath);
6923
+ }
6924
+ continue;
6925
+ }
6926
+ if (!entry.isFile()) {
6927
+ continue;
6928
+ }
6929
+ if (normalizedExtensions.length > 0) {
6930
+ const entryExtension = path.extname(entry.name).toLowerCase().replace(/^\./, "");
6931
+ if (!normalizedExtensions.includes(entryExtension)) {
6932
+ continue;
6933
+ }
6934
+ }
6935
+ total += 1;
6936
+ }
6937
+ }
6938
+ const extensionsLabel = normalizedExtensions.length > 0
6939
+ ? normalizedExtensions.map((extension) => `.${extension}`).join(", ")
6940
+ : "do tipo solicitado";
6941
+ return {
6942
+ total,
6943
+ path: directoryPath,
6944
+ extensions: normalizedExtensions,
6945
+ recursive,
6946
+ extensionsLabel,
6947
+ };
6948
+ }
6949
+ snapshotCpuTimes() {
6950
+ const cpus = os.cpus();
6951
+ let idle = 0;
6952
+ let total = 0;
6953
+ for (const cpu of cpus) {
6954
+ idle += cpu.times.idle;
6955
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
6956
+ }
6957
+ return {
6958
+ idle,
6959
+ total,
6960
+ model: cpus[0]?.model || "Apple Silicon",
6961
+ logicalCores: cpus.length || 0,
6962
+ };
6963
+ }
6964
+ async sampleCpuStatus() {
6965
+ const start = this.snapshotCpuTimes();
6966
+ await delay(320);
6967
+ const end = this.snapshotCpuTimes();
6968
+ const totalDelta = Math.max(1, end.total - start.total);
6969
+ const idleDelta = Math.max(0, end.idle - start.idle);
6970
+ const idlePercent = roundMetric((idleDelta / totalDelta) * 100, 1);
6971
+ const usagePercent = roundMetric(Math.max(0, 100 - idlePercent), 1);
6972
+ const [load1m, load5m, load15m] = os.loadavg();
6973
+ return {
6974
+ usage_percent: usagePercent,
6975
+ idle_percent: idlePercent,
6976
+ logical_cores: end.logicalCores,
6977
+ model: end.model,
6978
+ load_average_1m: roundMetric(load1m, 2),
6979
+ load_average_5m: roundMetric(load5m, 2),
6980
+ load_average_15m: roundMetric(load15m, 2),
6981
+ };
6982
+ }
6983
+ async readMemoryStatus() {
6984
+ const totalBytes = os.totalmem();
6985
+ const freeBytes = os.freemem();
6986
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
6987
+ let compressedBytes = 0;
6988
+ let swapUsedBytes = 0;
6989
+ try {
6990
+ const { stdout } = await this.runCommandCapture("/usr/bin/vm_stat", []);
6991
+ const pageSizeMatch = stdout.match(/page size of (\d+) bytes/i);
6992
+ const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 16384;
6993
+ const compressedMatch = stdout.match(/Pages occupied by compressor:\s+([0-9.]+)/i);
6994
+ if (compressedMatch) {
6995
+ compressedBytes = Math.round(Number(compressedMatch[1]) * pageSize);
6996
+ }
6997
+ }
6998
+ catch {
6999
+ compressedBytes = 0;
7000
+ }
7001
+ try {
7002
+ const { stdout } = await this.runCommandCapture("/usr/sbin/sysctl", ["vm.swapusage"]);
7003
+ const usedMatch = stdout.match(/used = ([0-9.]+)([BKMGTP]+)/i);
7004
+ if (usedMatch) {
7005
+ swapUsedBytes = parseScaledBytes(usedMatch[1], usedMatch[2]);
7006
+ }
7007
+ }
7008
+ catch {
5306
7009
  swapUsedBytes = 0;
5307
7010
  }
5308
- const usedPercent = totalBytes > 0 ? roundMetric((usedBytes / totalBytes) * 100, 1) : 0;
5309
- const pressure = (usedPercent >= 90 || swapUsedBytes >= 1.5 * 1024 ** 3)
5310
- ? "high"
5311
- : (usedPercent >= 80 || swapUsedBytes >= 512 * 1024 ** 2 || compressedBytes >= 1024 ** 3)
5312
- ? "attention"
5313
- : "normal";
7011
+ const usedPercent = totalBytes > 0 ? roundMetric((usedBytes / totalBytes) * 100, 1) : 0;
7012
+ const pressure = (usedPercent >= 90 || swapUsedBytes >= 1.5 * 1024 ** 3)
7013
+ ? "high"
7014
+ : (usedPercent >= 80 || swapUsedBytes >= 512 * 1024 ** 2 || compressedBytes >= 1024 ** 3)
7015
+ ? "attention"
7016
+ : "normal";
7017
+ return {
7018
+ total_bytes: totalBytes,
7019
+ used_bytes: usedBytes,
7020
+ free_bytes: freeBytes,
7021
+ used_percent: usedPercent,
7022
+ pressure,
7023
+ swap_used_bytes: swapUsedBytes || undefined,
7024
+ compressed_bytes: compressedBytes || undefined,
7025
+ };
7026
+ }
7027
+ async readDiskStatus() {
7028
+ const { stdout } = await this.runCommandCapture("/bin/df", ["-k", "/"]);
7029
+ const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
7030
+ if (lines.length < 2) {
7031
+ return undefined;
7032
+ }
7033
+ const parts = lines[1].trim().split(/\s+/);
7034
+ if (parts.length < 6) {
7035
+ return undefined;
7036
+ }
7037
+ const totalBytes = Number(parts[1]) * 1024;
7038
+ const usedBytes = Number(parts[2]) * 1024;
7039
+ const availableBytes = Number(parts[3]) * 1024;
7040
+ const usedPercent = roundMetric(Number((parts[4] || "").replace("%", "")), 1);
7041
+ return {
7042
+ mount_path: parts[5] || "/",
7043
+ total_bytes: totalBytes,
7044
+ used_bytes: usedBytes,
7045
+ available_bytes: availableBytes,
7046
+ used_percent: usedPercent,
7047
+ available_gb: roundMetric(availableBytes / (1024 ** 3), 1),
7048
+ };
7049
+ }
7050
+ async readBatteryStatus() {
7051
+ try {
7052
+ const { stdout } = await this.runCommandCapture("/usr/bin/pmset", ["-g", "batt"]);
7053
+ const percentageMatch = stdout.match(/(\d+)%/);
7054
+ if (!percentageMatch) {
7055
+ return undefined;
7056
+ }
7057
+ const powerSourceMatch = stdout.match(/Now drawing from '([^']+)'/i);
7058
+ const powerSource = powerSourceMatch?.[1]?.trim() || "Unknown";
7059
+ const normalized = stdout.toLowerCase();
7060
+ const charging = normalized.includes("charging") || normalized.includes("charged") || powerSource.toLowerCase().includes("ac");
7061
+ return {
7062
+ percentage: Math.max(0, Math.min(100, Number(percentageMatch[1]))),
7063
+ charging,
7064
+ power_source: powerSource,
7065
+ };
7066
+ }
7067
+ catch {
7068
+ return undefined;
7069
+ }
7070
+ }
7071
+ async readTopProcesses(limit = 4) {
7072
+ try {
7073
+ const { stdout } = await this.runCommandCapture("/bin/ps", ["-Ao", "pcpu,comm", "-r"]);
7074
+ const lines = stdout.trim().split(/\r?\n/).slice(1);
7075
+ return lines
7076
+ .map((line) => line.trim())
7077
+ .filter(Boolean)
7078
+ .slice(0, limit)
7079
+ .map((line) => {
7080
+ const match = line.match(/^([0-9.]+)\s+(.+)$/);
7081
+ if (!match)
7082
+ return null;
7083
+ const cpuPercent = roundMetric(Number(match[1]), 1);
7084
+ const command = match[2].trim().split("/").pop() || match[2].trim();
7085
+ return {
7086
+ name: command,
7087
+ cpu_percent: cpuPercent,
7088
+ };
7089
+ })
7090
+ .filter((item) => item !== null && item.cpu_percent > 0);
7091
+ }
7092
+ catch {
7093
+ return [];
7094
+ }
7095
+ }
7096
+ buildSystemStatusSummary(status) {
7097
+ const parts = [];
7098
+ const warnings = [];
7099
+ if (status.cpu) {
7100
+ parts.push(`CPU em ${roundMetric(status.cpu.usage_percent)}%`);
7101
+ if (status.cpu.usage_percent >= 85) {
7102
+ warnings.push(`CPU em ${roundMetric(status.cpu.usage_percent)}%`);
7103
+ }
7104
+ }
7105
+ if (status.memory) {
7106
+ parts.push(`memoria em ${roundMetric(status.memory.used_percent)}%`);
7107
+ if (status.memory.pressure === "high") {
7108
+ warnings.push(`memoria pressionada (${roundMetric(status.memory.used_percent)}% e swap ativo)`);
7109
+ }
7110
+ else if (status.memory.pressure === "attention") {
7111
+ warnings.push(`memoria em ${roundMetric(status.memory.used_percent)}%`);
7112
+ }
7113
+ }
7114
+ if (status.disk) {
7115
+ parts.push(`${formatBytesCompact(status.disk.available_bytes)} livres no disco`);
7116
+ if (status.disk.used_percent >= 90 || status.disk.available_bytes <= 15 * 1024 ** 3) {
7117
+ warnings.push(`pouco espaco livre (${formatBytesCompact(status.disk.available_bytes)})`);
7118
+ }
7119
+ }
7120
+ if (status.battery) {
7121
+ parts.push(`bateria em ${status.battery.percentage}%${status.battery.charging ? " carregando" : ""}`);
7122
+ if (!status.battery.charging && status.battery.percentage <= 20) {
7123
+ warnings.push(`bateria em ${status.battery.percentage}%`);
7124
+ }
7125
+ }
7126
+ let summary = warnings.length > 0
7127
+ ? `Seu Mac esta operando, mas merece atencao em ${warnings.join(" e ")}.`
7128
+ : "No geral, seu Mac esta de boa.";
7129
+ if (parts.length > 0) {
7130
+ summary += ` Agora vejo ${parts.join(", ")}.`;
7131
+ }
7132
+ if (status.top_processes && status.top_processes.length > 0) {
7133
+ const topProcesses = status.top_processes
7134
+ .slice(0, 3)
7135
+ .map((item) => `${item.name} (${roundMetric(item.cpu_percent)}%)`)
7136
+ .join(", ");
7137
+ if (topProcesses) {
7138
+ summary += ` Maiores consumos agora: ${topProcesses}.`;
7139
+ }
7140
+ }
7141
+ return summary;
7142
+ }
7143
+ async collectSystemStatus(sections, includeTopProcesses = true) {
7144
+ const requestedSections = sections && sections.length > 0
7145
+ ? sections
7146
+ : ["cpu", "memory", "disk", "battery"];
7147
+ const uniqueSections = Array.from(new Set(requestedSections));
7148
+ const status = {
7149
+ captured_at: new Date().toISOString(),
7150
+ hostname: os.hostname(),
7151
+ platform: process.platform,
7152
+ requested_sections: uniqueSections,
7153
+ summary: "",
7154
+ };
7155
+ if (uniqueSections.includes("cpu")) {
7156
+ status.cpu = await this.sampleCpuStatus();
7157
+ }
7158
+ if (uniqueSections.includes("memory")) {
7159
+ status.memory = await this.readMemoryStatus();
7160
+ }
7161
+ if (uniqueSections.includes("disk")) {
7162
+ status.disk = await this.readDiskStatus();
7163
+ }
7164
+ if (uniqueSections.includes("battery")) {
7165
+ status.battery = await this.readBatteryStatus();
7166
+ }
7167
+ if (includeTopProcesses && (uniqueSections.includes("cpu") || uniqueSections.includes("memory"))) {
7168
+ const topProcesses = await this.readTopProcesses();
7169
+ if (topProcesses && topProcesses.length > 0) {
7170
+ status.top_processes = topProcesses;
7171
+ }
7172
+ }
7173
+ status.summary = this.buildSystemStatusSummary(status);
7174
+ return status;
7175
+ }
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;
5314
7690
  return {
5315
- total_bytes: totalBytes,
5316
- used_bytes: usedBytes,
5317
- free_bytes: freeBytes,
5318
- used_percent: usedPercent,
5319
- pressure,
5320
- swap_used_bytes: swapUsedBytes || undefined,
5321
- compressed_bytes: compressedBytes || undefined,
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}.`,
5322
7702
  };
5323
7703
  }
5324
- async readDiskStatus() {
5325
- const { stdout } = await this.runCommandCapture("/bin/df", ["-k", "/"]);
5326
- const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
5327
- if (lines.length < 2) {
5328
- return undefined;
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
+ };
5329
7719
  }
5330
- const parts = lines[1].trim().split(/\s+/);
5331
- if (parts.length < 6) {
5332
- return undefined;
7720
+ const mergeArgs = ["merge"];
7721
+ if (options.ffOnly === true) {
7722
+ mergeArgs.push("--ff-only");
5333
7723
  }
5334
- const totalBytes = Number(parts[1]) * 1024;
5335
- const usedBytes = Number(parts[2]) * 1024;
5336
- const availableBytes = Number(parts[3]) * 1024;
5337
- const usedPercent = roundMetric(Number((parts[4] || "").replace("%", "")), 1);
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);
5338
7761
  return {
5339
- mount_path: parts[5] || "/",
5340
- total_bytes: totalBytes,
5341
- used_bytes: usedBytes,
5342
- available_bytes: availableBytes,
5343
- used_percent: usedPercent,
5344
- available_gb: roundMetric(availableBytes / (1024 ** 3), 1),
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}.`,
5345
7775
  };
5346
7776
  }
5347
- async readBatteryStatus() {
5348
- try {
5349
- const { stdout } = await this.runCommandCapture("/usr/bin/pmset", ["-g", "batt"]);
5350
- const percentageMatch = stdout.match(/(\d+)%/);
5351
- if (!percentageMatch) {
5352
- return undefined;
5353
- }
5354
- const powerSourceMatch = stdout.match(/Now drawing from '([^']+)'/i);
5355
- const powerSource = powerSourceMatch?.[1]?.trim() || "Unknown";
5356
- const normalized = stdout.toLowerCase();
5357
- const charging = normalized.includes("charging") || normalized.includes("charged") || powerSource.toLowerCase().includes("ac");
7777
+ async gitTagSnapshot(cwd, options, workspaceContext) {
7778
+ const repo = await this.probeGitRepository(cwd, workspaceContext);
7779
+ if (!repo.isRepo) {
5358
7780
  return {
5359
- percentage: Math.max(0, Math.min(100, Number(percentageMatch[1]))),
5360
- charging,
5361
- power_source: powerSource,
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.`,
5362
7791
  };
5363
7792
  }
5364
- catch {
5365
- return undefined;
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);
5366
7799
  }
5367
- }
5368
- async readTopProcesses(limit = 4) {
5369
- try {
5370
- const { stdout } = await this.runCommandCapture("/bin/ps", ["-Ao", "pcpu,comm", "-r"]);
5371
- const lines = stdout.trim().split(/\r?\n/).slice(1);
5372
- return lines
5373
- .map((line) => line.trim())
5374
- .filter(Boolean)
5375
- .slice(0, limit)
5376
- .map((line) => {
5377
- const match = line.match(/^([0-9.]+)\s+(.+)$/);
5378
- if (!match)
5379
- return null;
5380
- const cpuPercent = roundMetric(Number(match[1]), 1);
5381
- const command = match[2].trim().split("/").pop() || match[2].trim();
5382
- return {
5383
- name: command,
5384
- cpu_percent: cpuPercent,
5385
- };
5386
- })
5387
- .filter((item) => item !== null && item.cpu_percent > 0);
7800
+ else {
7801
+ tagArgs.push(options.name);
5388
7802
  }
5389
- catch {
5390
- return [];
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
+ };
5391
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
+ };
5392
7848
  }
5393
- buildSystemStatusSummary(status) {
5394
- const parts = [];
5395
- const warnings = [];
5396
- if (status.cpu) {
5397
- parts.push(`CPU em ${roundMetric(status.cpu.usage_percent)}%`);
5398
- if (status.cpu.usage_percent >= 85) {
5399
- warnings.push(`CPU em ${roundMetric(status.cpu.usage_percent)}%`);
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;
5400
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
+ };
5401
7904
  }
5402
- if (status.memory) {
5403
- parts.push(`memoria em ${roundMetric(status.memory.used_percent)}%`);
5404
- if (status.memory.pressure === "high") {
5405
- warnings.push(`memoria pressionada (${roundMetric(status.memory.used_percent)}% e swap ativo)`);
5406
- }
5407
- else if (status.memory.pressure === "attention") {
5408
- warnings.push(`memoria em ${roundMetric(status.memory.used_percent)}%`);
5409
- }
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");
5410
7911
  }
5411
- if (status.disk) {
5412
- parts.push(`${formatBytesCompact(status.disk.available_bytes)} livres no disco`);
5413
- if (status.disk.used_percent >= 90 || status.disk.available_bytes <= 15 * 1024 ** 3) {
5414
- warnings.push(`pouco espaco livre (${formatBytesCompact(status.disk.available_bytes)})`);
5415
- }
7912
+ if (pathspecs.length > 0) {
7913
+ addArgs.push("--", ...pathspecs);
5416
7914
  }
5417
- if (status.battery) {
5418
- parts.push(`bateria em ${status.battery.percentage}%${status.battery.charging ? " carregando" : ""}`);
5419
- if (!status.battery.charging && status.battery.percentage <= 20) {
5420
- warnings.push(`bateria em ${status.battery.percentage}%`);
5421
- }
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
+ };
5422
7934
  }
5423
- let summary = warnings.length > 0
5424
- ? `Seu Mac esta operando, mas merece atencao em ${warnings.join(" e ")}.`
5425
- : "No geral, seu Mac esta de boa.";
5426
- if (parts.length > 0) {
5427
- summary += ` Agora vejo ${parts.join(", ")}.`;
7935
+ const nameOnlyArgs = ["diff", "--cached", "--name-only"];
7936
+ if (pathspecs.length > 0) {
7937
+ nameOnlyArgs.push("--", ...pathspecs);
5428
7938
  }
5429
- if (status.top_processes && status.top_processes.length > 0) {
5430
- const topProcesses = status.top_processes
5431
- .slice(0, 3)
5432
- .map((item) => `${item.name} (${roundMetric(item.cpu_percent)}%)`)
5433
- .join(", ");
5434
- if (topProcesses) {
5435
- summary += ` Maiores consumos agora: ${topProcesses}.`;
5436
- }
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
+ };
5437
7980
  }
5438
- return summary;
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
+ };
5439
8027
  }
5440
- async collectSystemStatus(sections, includeTopProcesses = true) {
5441
- const requestedSections = sections && sections.length > 0
5442
- ? sections
5443
- : ["cpu", "memory", "disk", "battery"];
5444
- const uniqueSections = Array.from(new Set(requestedSections));
5445
- const status = {
5446
- captured_at: new Date().toISOString(),
5447
- hostname: os.hostname(),
5448
- platform: process.platform,
5449
- requested_sections: uniqueSections,
5450
- summary: "",
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}` : ""}.`,
5451
8106
  };
5452
- if (uniqueSections.includes("cpu")) {
5453
- status.cpu = await this.sampleCpuStatus();
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
+ };
5454
8134
  }
5455
- if (uniqueSections.includes("memory")) {
5456
- status.memory = await this.readMemoryStatus();
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
+ };
5457
8208
  }
5458
- if (uniqueSections.includes("disk")) {
5459
- status.disk = await this.readDiskStatus();
8209
+ const repoRoot = repoProbe.stdout.trim() || resolvedCwd;
8210
+ const diffArgs = ["diff", "--no-color"];
8211
+ if (staged) {
8212
+ diffArgs.push("--cached");
5460
8213
  }
5461
- if (uniqueSections.includes("battery")) {
5462
- status.battery = await this.readBatteryStatus();
8214
+ if (baseRef) {
8215
+ diffArgs.push(baseRef);
5463
8216
  }
5464
- if (includeTopProcesses && (uniqueSections.includes("cpu") || uniqueSections.includes("memory"))) {
5465
- const topProcesses = await this.readTopProcesses();
5466
- if (topProcesses && topProcesses.length > 0) {
5467
- status.top_processes = topProcesses;
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;
5468
8305
  }
5469
8306
  }
5470
- status.summary = this.buildSystemStatusSummary(status);
5471
- return status;
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
+ };
5472
8338
  }
5473
- async runShellCommand(command, cwd) {
8339
+ async runShellCommand(command, cwd, workspaceContext) {
5474
8340
  if (!isSafeShellCommand(command)) {
5475
8341
  throw new Error("Nenhum comando shell foi informado para execucao local.");
5476
8342
  }
5477
- const resolvedCwd = cwd ? expandUserPath(cwd) : process.cwd();
8343
+ const resolvedCwd = this.resolveWorkspaceExecutionCwd(cwd || "", workspaceContext);
5478
8344
  const { stdout, stderr } = await this.runCommandCapture("/bin/zsh", ["-lc", command], {
5479
8345
  cwd: resolvedCwd,
5480
8346
  });
@@ -5535,15 +8401,72 @@ if let output = String(data: data, encoding: .utf8) {
5535
8401
  if (action.type === "read_file") {
5536
8402
  return `${action.path} foi lido no macOS`;
5537
8403
  }
8404
+ if (action.type === "trash_path") {
8405
+ return `${action.path} foi movido para a Lixeira`;
8406
+ }
8407
+ if (action.type === "write_text_file") {
8408
+ return `Arquivo de texto escrito em ${action.filename ? `${action.path}/${action.filename}` : action.path}`;
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
+ }
5538
8449
  if (action.type === "list_files") {
5539
8450
  return `Arquivos listados em ${action.path}`;
5540
8451
  }
8452
+ if (action.type === "delete_file") {
8453
+ return `Arquivo apagado em ${action.path}`;
8454
+ }
5541
8455
  if (action.type === "count_files") {
5542
8456
  return `Arquivos contados em ${action.path}`;
5543
8457
  }
5544
8458
  if (action.type === "system_status") {
5545
8459
  return "Status do macOS coletado";
5546
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
+ }
5547
8470
  if (action.type === "run_shell") {
5548
8471
  return `Comando ${action.command} executado no macOS`;
5549
8472
  }
@@ -5578,9 +8501,31 @@ if let output = String(data: data, encoding: .utf8) {
5578
8501
  });
5579
8502
  this.activeChild = child;
5580
8503
  try {
5581
- const { stdout, stderr } = await new Promise((resolve, reject) => {
8504
+ const { stdout, stderr, exitCode, timedOut } = await new Promise((resolve, reject) => {
5582
8505
  let stdout = "";
5583
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
+ };
5584
8529
  if (options?.stdin !== undefined) {
5585
8530
  child.stdin.write(options.stdin);
5586
8531
  child.stdin.end();
@@ -5595,21 +8540,36 @@ if let output = String(data: data, encoding: .utf8) {
5595
8540
  stderr += String(chunk);
5596
8541
  });
5597
8542
  child.on("error", (error) => {
8543
+ if (settled) {
8544
+ return;
8545
+ }
8546
+ settled = true;
8547
+ clearTimer();
5598
8548
  reject(error);
5599
8549
  });
5600
8550
  child.on("close", (code) => {
5601
- if (code === 0) {
5602
- 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 });
5603
8563
  return;
5604
8564
  }
5605
- 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}`}`));
5606
8566
  });
5607
8567
  });
5608
8568
  const stderrText = stderr.trim();
5609
8569
  if (stderrText) {
5610
8570
  console.warn(`[otto-bridge] ${command} stderr=${stderrText}`);
5611
8571
  }
5612
- return { stdout, stderr };
8572
+ return { stdout, stderr, exitCode, timedOut };
5613
8573
  }
5614
8574
  catch (error) {
5615
8575
  const detail = error instanceof Error ? error.message : String(error);