@astroanywhere/agent 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -0
  2. package/dist/lib/git-pr.d.ts +83 -11
  3. package/dist/lib/git-pr.d.ts.map +1 -1
  4. package/dist/lib/git-pr.js +249 -33
  5. package/dist/lib/git-pr.js.map +1 -1
  6. package/dist/lib/local-merge.d.ts +3 -1
  7. package/dist/lib/local-merge.d.ts.map +1 -1
  8. package/dist/lib/local-merge.js +29 -7
  9. package/dist/lib/local-merge.js.map +1 -1
  10. package/dist/lib/task-executor.d.ts.map +1 -1
  11. package/dist/lib/task-executor.js +101 -53
  12. package/dist/lib/task-executor.js.map +1 -1
  13. package/dist/lib/websocket-client.d.ts +6 -2
  14. package/dist/lib/websocket-client.d.ts.map +1 -1
  15. package/dist/lib/websocket-client.js +28 -5
  16. package/dist/lib/websocket-client.js.map +1 -1
  17. package/dist/lib/worktree.d.ts +3 -3
  18. package/dist/lib/worktree.d.ts.map +1 -1
  19. package/dist/lib/worktree.js +92 -48
  20. package/dist/lib/worktree.js.map +1 -1
  21. package/dist/providers/base-adapter.d.ts +29 -2
  22. package/dist/providers/base-adapter.d.ts.map +1 -1
  23. package/dist/providers/base-adapter.js +39 -1
  24. package/dist/providers/base-adapter.js.map +1 -1
  25. package/dist/providers/claude-sdk-adapter.d.ts +2 -4
  26. package/dist/providers/claude-sdk-adapter.d.ts.map +1 -1
  27. package/dist/providers/claude-sdk-adapter.js +46 -55
  28. package/dist/providers/claude-sdk-adapter.js.map +1 -1
  29. package/dist/providers/codex-adapter.d.ts.map +1 -1
  30. package/dist/providers/codex-adapter.js +6 -3
  31. package/dist/providers/codex-adapter.js.map +1 -1
  32. package/dist/providers/openclaw-adapter.d.ts.map +1 -1
  33. package/dist/providers/openclaw-adapter.js +4 -2
  34. package/dist/providers/openclaw-adapter.js.map +1 -1
  35. package/dist/providers/opencode-adapter.d.ts.map +1 -1
  36. package/dist/providers/opencode-adapter.js +6 -4
  37. package/dist/providers/opencode-adapter.js.map +1 -1
  38. package/dist/providers/pi-adapter.d.ts +7 -0
  39. package/dist/providers/pi-adapter.d.ts.map +1 -1
  40. package/dist/providers/pi-adapter.js +246 -70
  41. package/dist/providers/pi-adapter.js.map +1 -1
  42. package/dist/types.d.ts +16 -2
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +2 -2
@@ -16,7 +16,7 @@ import { SlurmJobMonitor } from './slurm-job-monitor.js';
16
16
  import { createWorktree, syncProjectWorktree } from './worktree.js';
17
17
  import { ensureProjectWorkspace } from './workspace-root.js';
18
18
  import { BranchLockManager } from './branch-lock.js';
19
- import { pushAndCreatePR, mergePullRequest, getRemoteBranchSha, isGhAvailable } from './git-pr.js';
19
+ import { pushAndCreatePR, mergePullRequest, getRemoteBranchSha, isGhAvailable, getRepoSlug } from './git-pr.js';
20
20
  import { localMergeIntoProjectBranch } from './local-merge.js';
21
21
  import { checkWorkdirSafety, isGitAvailable, isGitRepo, isUntrackedInParentRepo, createSandbox, WorkdirSafetyTier, } from './workdir-safety.js';
22
22
  import { initializeGit } from './git-bootstrap.js';
@@ -94,22 +94,45 @@ After you force-push, I will automatically retry the GitHub merge.`;
94
94
  * existing merge retry loop handles real conflicts.
95
95
  */
96
96
  async function tryPreMergeRebase(workdir, targetBranch, isRemote) {
97
+ // `git rebase` always operates on the currently checked-out branch (HEAD).
98
+ // It cannot rebase a detached branch ref. When the worktree is gone, HEAD
99
+ // in gitRoot is the user's main checkout (e.g., main) — NOT the task branch.
100
+ // Rebasing from gitRoot would corrupt the user's working directory.
101
+ // Skip the rebase entirely; the merge/PR flow handles conflicts anyway.
102
+ //
103
+ // Best-effort check — worktree could still be deleted after this (TOCTOU),
104
+ // but the git commands will fail safely and be caught by the outer catch.
105
+ try {
106
+ statSync(workdir);
107
+ }
108
+ catch {
109
+ console.log(`[git] Worktree at ${workdir} gone — skipping rebase (cannot rebase non-checked-out branch)`);
110
+ return { rebased: false, skipped: true };
111
+ }
97
112
  try {
98
113
  const rebaseTarget = isRemote ? `origin/${targetBranch}` : targetBranch;
114
+ const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
99
115
  if (isRemote) {
100
- await execFileAsync('git', ['fetch', 'origin', targetBranch], { cwd: workdir, timeout: 30_000 });
116
+ console.log(`[git] fetch origin ${targetBranch} (cwd: ${workdir})`);
117
+ await execFileAsync('git', ['fetch', 'origin', targetBranch], { cwd: workdir, env: gitEnv, timeout: 30_000 });
101
118
  }
102
119
  // Check if rebase is needed (target branch moved since we branched)
103
- const { stdout: mergeBase } = await execFileAsync('git', ['merge-base', 'HEAD', rebaseTarget], { cwd: workdir });
104
- const { stdout: targetTip } = await execFileAsync('git', ['rev-parse', rebaseTarget], { cwd: workdir });
120
+ console.log(`[git] merge-base HEAD ${rebaseTarget} (cwd: ${workdir})`);
121
+ const { stdout: mergeBase } = await execFileAsync('git', ['merge-base', 'HEAD', rebaseTarget], { cwd: workdir, env: gitEnv });
122
+ const { stdout: targetTip } = await execFileAsync('git', ['rev-parse', rebaseTarget], { cwd: workdir, env: gitEnv });
105
123
  if (mergeBase.trim() === targetTip.trim()) {
106
- return { rebased: false, skipped: true }; // Already up to date
124
+ console.log(`[git] Already up to date (merge-base = target tip: ${targetTip.trim().slice(0, 7)})`);
125
+ return { rebased: false, skipped: true };
107
126
  }
108
127
  // Try automatic rebase — timeout after 60s (should be fast for non-conflicting changes)
109
- await execFileAsync('git', ['rebase', rebaseTarget], { cwd: workdir, timeout: 60_000 });
128
+ console.log(`[git] rebase ${rebaseTarget} (cwd: ${workdir})`);
129
+ await execFileAsync('git', ['rebase', rebaseTarget], { cwd: workdir, env: gitEnv, timeout: 60_000 });
130
+ console.log(`[git] Rebase onto ${rebaseTarget} succeeded`);
110
131
  return { rebased: true };
111
132
  }
112
- catch {
133
+ catch (err) {
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ console.warn(`[git] Rebase onto ${targetBranch} failed: ${msg}`);
113
136
  // Abort on failure — existing retry loop will handle conflicts
114
137
  await execFileAsync('git', ['rebase', '--abort'], { cwd: workdir }).catch(() => { });
115
138
  return { rebased: false };
@@ -214,12 +237,15 @@ export class TaskExecutor {
214
237
  }
215
238
  this.claimedTasks.add(task.id);
216
239
  try {
217
- // Skip workingDirectory resolution for lightweight text-only tasks (no file system access)
218
- const isTextOnlyTask = task.type === 'summarize' || task.type === 'chat' || task.type === 'plan';
219
- // Text-only tasks (plan/chat/summarize) can run without a working directory.
220
- // For all others, resolve the directory or auto-provision one.
240
+ // Only execution tasks need workingDirectory resolution, safety checks,
241
+ // git diff, and summary generation. All other types (plan/chat/summarize/
242
+ // playground) skip this overhead but still get full tool access when they
243
+ // have a working directory.
244
+ const isExecutionTask = !task.type || task.type === 'execution';
245
+ // Non-execution tasks can run without a working directory.
246
+ // For execution tasks, resolve the directory or auto-provision one.
221
247
  let resolvedWorkDir;
222
- if (isTextOnlyTask && !task.workingDirectory) {
248
+ if (!isExecutionTask && !task.workingDirectory) {
223
249
  resolvedWorkDir = undefined;
224
250
  }
225
251
  else {
@@ -251,14 +277,14 @@ export class TaskExecutor {
251
277
  // thinks worktree isolation is active and allows parallel execution, but
252
278
  // prepareTaskWorkspace() later falls back to direct in-place execution —
253
279
  // causing file conflicts when multiple tasks run on the same non-git directory.
254
- const isGitDir = !isTextOnlyTask && normalizedTask.workingDirectory && this.gitAvailable
280
+ const isGitDir = isExecutionTask && normalizedTask.workingDirectory && this.gitAvailable
255
281
  ? await isGitRepo(normalizedTask.workingDirectory)
256
282
  : false;
257
283
  const willUseWorktree = this.useWorktree
258
284
  && normalizedTask.useWorktree !== false
259
285
  && normalizedTask.deliveryMode !== 'direct'
260
286
  && (isGitDir || normalizedTask.deliveryMode === 'copy');
261
- if (!isTextOnlyTask && task.skipSafetyCheck) {
287
+ if (isExecutionTask && task.skipSafetyCheck) {
262
288
  // Server already approved safety for this directory — skip the prompt.
263
289
  // Only init git when the original safety decision was 'init-git'.
264
290
  // When safetyDecision is 'proceed' (user chose non-git direct execution)
@@ -279,7 +305,7 @@ export class TaskExecutor {
279
305
  }
280
306
  return;
281
307
  }
282
- if (!isTextOnlyTask) {
308
+ if (isExecutionTask) {
283
309
  // Perform safety check (worktree flag affects tier assignment)
284
310
  const safetyCheck = await this.performSafetyCheck(normalizedTask, willUseWorktree);
285
311
  // Handle safety tiers
@@ -571,11 +597,14 @@ export class TaskExecutor {
571
597
  text: (data) => {
572
598
  this.wsClient.sendTaskText(taskId, data, textSequence++);
573
599
  },
574
- toolUse: (toolName, toolInput) => {
575
- this.wsClient.sendTaskToolUse(taskId, toolName, toolInput);
600
+ operational: (message, source) => {
601
+ this.wsClient.sendTaskOperational(taskId, message, source);
602
+ },
603
+ toolUse: (toolName, toolInput, toolUseId) => {
604
+ this.wsClient.sendTaskToolUse(taskId, toolName, toolInput, toolUseId);
576
605
  },
577
- toolResult: (toolName, result, success) => {
578
- this.wsClient.sendTaskToolResult(taskId, toolName, result, success);
606
+ toolResult: (toolName, result, success, toolUseId) => {
607
+ this.wsClient.sendTaskToolResult(taskId, toolName, result, success, toolUseId);
579
608
  },
580
609
  fileChange: (path, action, linesAdded, linesRemoved, diff) => {
581
610
  this.wsClient.sendTaskFileChange(taskId, path, action, linesAdded, linesRemoved, diff);
@@ -607,6 +636,7 @@ export class TaskExecutor {
607
636
  projectBranch: meta.projectBranch,
608
637
  stdout: stream.stdout,
609
638
  stderr: stream.stderr,
639
+ operational: stream.operational,
610
640
  });
611
641
  if (worktree) {
612
642
  resumeDir = worktree.workingDirectory;
@@ -902,13 +932,17 @@ export class TaskExecutor {
902
932
  resetIdleTimeout();
903
933
  this.wsClient.sendTaskText(normalizedTask.id, data, textSequence++);
904
934
  },
905
- toolUse: (toolName, toolInput) => {
935
+ operational: (message, source) => {
936
+ resetIdleTimeout();
937
+ this.wsClient.sendTaskOperational(normalizedTask.id, message, source);
938
+ },
939
+ toolUse: (toolName, toolInput, toolUseId) => {
906
940
  resetIdleTimeout();
907
- this.wsClient.sendTaskToolUse(normalizedTask.id, toolName, toolInput);
941
+ this.wsClient.sendTaskToolUse(normalizedTask.id, toolName, toolInput, toolUseId);
908
942
  },
909
- toolResult: (toolName, result, success) => {
943
+ toolResult: (toolName, result, success, toolUseId) => {
910
944
  resetIdleTimeout();
911
- this.wsClient.sendTaskToolResult(normalizedTask.id, toolName, result, success);
945
+ this.wsClient.sendTaskToolResult(normalizedTask.id, toolName, result, success, toolUseId);
912
946
  },
913
947
  fileChange: (path, action, linesAdded, linesRemoved, diff) => {
914
948
  resetIdleTimeout();
@@ -947,20 +981,22 @@ export class TaskExecutor {
947
981
  // 30s aligns with the server's heartbeat check interval and is well under
948
982
  // the 3-minute startup timeout (STARTUP_TIMEOUT_MS in dispatch.ts).
949
983
  const TASK_HEARTBEAT_INTERVAL_MS = 30_000;
950
- let taskHeartbeatPhase = 'preparing';
951
984
  let heartbeatSeq = 0;
952
985
  const taskHeartbeatTimer = setInterval(() => {
953
986
  // Send directly via wsClient to keep the server's activity timer alive
954
987
  // WITHOUT resetting the agent-side idle timeout. stream.text() calls
955
988
  // resetIdleTimeout(), which would make a hung agent run until hard cap
956
989
  // instead of idle-timing out after 15 minutes of no real activity.
957
- this.wsClient.sendTaskText(normalizedTask.id, `[Astro] Heartbeat — ${taskHeartbeatPhase}...\n`, -(++heartbeatSeq));
990
+ // Heartbeat text is intentionally empty it exists only to keep the
991
+ // server's activity timer alive, not to display anything to the user.
992
+ this.wsClient.sendTaskText(normalizedTask.id, '', -(++heartbeatSeq));
958
993
  }, TASK_HEARTBEAT_INTERVAL_MS);
959
- // Text-only tasks (plan/chat/summarize) without a working directory skip workspace prep
960
- const isTextOnly = normalizedTask.type === 'summarize' || normalizedTask.type === 'chat' || normalizedTask.type === 'plan';
994
+ // Only execution tasks get git diff, delivery, and summary — all other types (chat, plan, playground, summarize) skip them.
995
+ // These non-execution types still get full tool access when they have a working directory.
996
+ const isExecutionTask = !normalizedTask.type || normalizedTask.type === 'execution';
961
997
  let prepared;
962
998
  try {
963
- prepared = isTextOnly && !normalizedTask.workingDirectory
999
+ prepared = !isExecutionTask && !normalizedTask.workingDirectory
964
1000
  ? { workingDirectory: '', cleanup: async () => { } }
965
1001
  : await this.prepareTaskWorkspace(normalizedTask, stream, abortController.signal);
966
1002
  }
@@ -975,7 +1011,6 @@ export class TaskExecutor {
975
1011
  }
976
1012
  const taskWithWorkspace = { ...normalizedTask, workingDirectory: prepared.workingDirectory };
977
1013
  runningTask.task = taskWithWorkspace;
978
- taskHeartbeatPhase = 'executing';
979
1014
  console.log(`[executor] Task ${task.id}: workspace prepared, cwd=${prepared.workingDirectory}`);
980
1015
  // Execute with idle timeout + hard cap.
981
1016
  // Idle timeout resets on every stream activity (text, tool, file, etc.).
@@ -999,7 +1034,7 @@ export class TaskExecutor {
999
1034
  // Resume existing session if resumeSessionId is provided
1000
1035
  const canResume = taskWithWorkspace.resumeSessionId
1001
1036
  && this.isResumableAdapter(adapter)
1002
- && isTextOnly;
1037
+ && !isExecutionTask;
1003
1038
  let result;
1004
1039
  if (canResume) {
1005
1040
  console.log(`[executor] Task ${task.id}: resuming session ${taskWithWorkspace.resumeSessionId} with ${adapter.name} (type=${adapter.type}, hasMessages=${!!taskWithWorkspace.messages?.length})...`);
@@ -1052,7 +1087,7 @@ export class TaskExecutor {
1052
1087
  });
1053
1088
  }
1054
1089
  // Emit accurate file change events via git diff (covers all change methods)
1055
- if (!isTextOnly && prepared.workingDirectory) {
1090
+ if (isExecutionTask && prepared.workingDirectory) {
1056
1091
  await this.emitGitDiffFileChanges(task.id, prepared.workingDirectory, prepared.commitBeforeSha, stream);
1057
1092
  }
1058
1093
  // Delivery-mode-aware result handling
@@ -1070,7 +1105,7 @@ export class TaskExecutor {
1070
1105
  if (summary) {
1071
1106
  console.log(`[executor] Task ${task.id}: summary available — status=${summary.status}, workCompleted=${!!summary.workCompleted}, executiveSummary=${!!summary.executiveSummary}, keyFindings=${summary.keyFindings?.length ?? 0}, filesChanged=${summary.filesChanged?.length ?? 0}`);
1072
1107
  }
1073
- else if (!isTextOnly) {
1108
+ else if (isExecutionTask) {
1074
1109
  console.warn(`[executor] Task ${task.id}: no summary available for PR body`);
1075
1110
  }
1076
1111
  const rawTitle = summary?.workCompleted || task.title || task.prompt.slice(0, 100);
@@ -1078,9 +1113,12 @@ export class TaskExecutor {
1078
1113
  ? `[${task.shortProjectId}/${task.shortNodeId}] ${rawTitle}`
1079
1114
  : rawTitle;
1080
1115
  if (prepared.branchName && result.status === 'completed') {
1081
- taskHeartbeatPhase = 'delivering';
1082
- stream.text?.(`\n[Astro] Delivering changes (mode: ${deliveryMode})...\n`);
1116
+ stream.operational?.(`Delivering changes (mode: ${deliveryMode})...`, 'astro');
1083
1117
  this.wsClient.sendTaskStatus(task.id, 'running', 90, 'Delivering changes...');
1118
+ // Resolve GitHub repo slug (OWNER/REPO) for explicit gh --repo targeting.
1119
+ // This makes gh pr create/merge independent of local filesystem state
1120
+ // (worktree may be cleaned up by the agent during execution).
1121
+ const repoSlug = prepared.gitRoot ? await getRepoSlug(prepared.gitRoot) : undefined;
1084
1122
  // Build PR body: enrich with summary data when available
1085
1123
  const prBodyParts = [];
1086
1124
  if (summary) {
@@ -1113,14 +1151,16 @@ export class TaskExecutor {
1113
1151
  try {
1114
1152
  if (deliveryMode === 'direct') {
1115
1153
  // No git delivery — files modified in-place
1154
+ stream.operational?.('Changes applied in-place (direct mode)', 'delivery');
1116
1155
  console.log(`[executor] Task ${task.id}: direct mode, skipping git delivery`);
1117
1156
  }
1118
1157
  else if (deliveryMode === 'copy') {
1119
1158
  // Copy mode: worktree preserved, no git operations
1159
+ stream.operational?.('Worktree preserved (copy mode)', 'delivery');
1120
1160
  console.log(`[executor] Task ${task.id}: copy mode, worktree preserved at ${prepared.workingDirectory}`);
1121
1161
  }
1122
1162
  else if (deliveryMode === 'branch') {
1123
- stream.text?.(`[Astro] Merging into project branch ${prepared.projectBranch ?? 'local'}...\n`);
1163
+ stream.operational?.(`Merging into project branch ${prepared.projectBranch ?? 'local'}...`, 'git');
1124
1164
  // Branch mode: commit locally, merge into project branch if available.
1125
1165
  // The merge lock is held only during the squash-merge (seconds, not minutes),
1126
1166
  // allowing tasks to execute in parallel. The squash merge naturally handles
@@ -1138,9 +1178,11 @@ export class TaskExecutor {
1138
1178
  // don't overlap, and saves one retry cycle when they do.
1139
1179
  const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.projectBranch, false);
1140
1180
  if (preRebase.rebased) {
1181
+ stream.operational?.(`Rebased onto ${prepared.projectBranch}`, 'git');
1141
1182
  console.log(`[executor] Task ${task.id}: pre-merge rebase onto ${prepared.projectBranch} succeeded`);
1142
1183
  }
1143
1184
  else if (!preRebase.skipped) {
1185
+ stream.operational?.('Rebase skipped (conflicts), retrying via merge', 'git');
1144
1186
  console.log(`[executor] Task ${task.id}: pre-merge rebase had conflicts, falling back to merge retry loop`);
1145
1187
  }
1146
1188
  const mergeLockKey = BranchLockManager.computeLockKey(prepared.gitRoot, task.shortProjectId, task.shortNodeId, task.id);
@@ -1153,7 +1195,7 @@ export class TaskExecutor {
1153
1195
  let mergeResult;
1154
1196
  try {
1155
1197
  this.wsClient.sendTaskStatus(task.id, 'running', 96, 'Merging into project branch...');
1156
- mergeResult = await localMergeIntoProjectBranch(prepared.gitRoot, prepared.branchName, prepared.projectBranch, commitMessage);
1198
+ mergeResult = await localMergeIntoProjectBranch(prepared.gitRoot, prepared.branchName, prepared.projectBranch, commitMessage, (msg) => stream.operational?.(msg, 'git'));
1157
1199
  }
1158
1200
  finally {
1159
1201
  mergeLock.release();
@@ -1162,7 +1204,7 @@ export class TaskExecutor {
1162
1204
  if (mergeResult.merged) {
1163
1205
  result.deliveryStatus = 'success';
1164
1206
  result.commitAfterSha = mergeResult.commitSha;
1165
- stream.text?.(`[Astro] Merged into ${prepared.projectBranch} (${mergeResult.commitSha?.slice(0, 7)})\n`);
1207
+ stream.operational?.(`Merged into ${prepared.projectBranch} (${mergeResult.commitSha?.slice(0, 7)})`, 'git');
1166
1208
  console.log(`[executor] Task ${task.id}: merged into ${prepared.projectBranch} (${mergeResult.commitSha})`);
1167
1209
  // Sync project worktree to reflect the merged changes on disk
1168
1210
  if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
@@ -1178,7 +1220,7 @@ export class TaskExecutor {
1178
1220
  : null;
1179
1221
  if (taskContext?.sessionId && this.isResumableAdapter(adapter) && attempt < MAX_MERGE_ATTEMPTS) {
1180
1222
  const conflictFiles = mergeResult.conflictFiles?.join(', ') ?? 'unknown files';
1181
- stream.text?.(`[Astro] Merge conflict in: ${conflictFiles} — agent resolving (attempt ${attempt})...\n`);
1223
+ stream.operational?.(`Merge conflict: ${conflictFiles} — agent resolving (attempt ${attempt})...`, 'git');
1182
1224
  console.log(`[executor] Task ${task.id}: merge conflict (attempt ${attempt}), resuming ${adapter.name} to resolve: ${conflictFiles}`);
1183
1225
  this.wsClient.sendTaskStatus(task.id, 'running', 97, `Merge conflict — agent resolving (attempt ${attempt})...`);
1184
1226
  // Resume agent session with conflict resolution instructions.
@@ -1227,7 +1269,7 @@ export class TaskExecutor {
1227
1269
  }
1228
1270
  else if (deliveryMode === 'push') {
1229
1271
  // Push branch to remote, but don't create a PR — user creates PR manually
1230
- stream.text?.(`[Astro] Pushing branch ${prepared.branchName} to origin...\n`);
1272
+ stream.operational?.(`Pushing ${prepared.branchName} to origin...`, 'git');
1231
1273
  this.wsClient.sendTaskStatus(task.id, 'running', 95, 'Pushing branch...');
1232
1274
  console.log(`[executor] Task ${task.id}: push mode, pushing branch ${prepared.branchName}`);
1233
1275
  const prResult = await pushAndCreatePR(prepared.workingDirectory, {
@@ -1236,30 +1278,31 @@ export class TaskExecutor {
1236
1278
  taskDescription: task.description || task.prompt.slice(0, 500),
1237
1279
  skipPR: true,
1238
1280
  baseBranch: prepared.baseBranch,
1281
+ gitRoot: prepared.gitRoot,
1239
1282
  });
1240
1283
  result.branchName = prResult.branchName;
1241
1284
  if (prResult.error) {
1242
1285
  // Delivery failure — don't override execution status
1243
1286
  result.deliveryStatus = 'failed';
1244
1287
  result.deliveryError = `Push delivery failed: ${prResult.error}`;
1245
- stream.text?.(`[Astro] Push failed: ${prResult.error}\n`);
1288
+ stream.operational?.(`Push failed: ${prResult.error}`, 'git');
1246
1289
  console.error(`[executor] Task ${task.id}: push delivery failed: ${prResult.error}`);
1247
1290
  }
1248
1291
  else if (prResult.pushed) {
1249
1292
  result.deliveryStatus = 'success';
1250
1293
  keepBranch = true;
1251
- stream.text?.(`[Astro] Branch pushed to origin: ${prepared.branchName}\n`);
1294
+ stream.operational?.(`Pushed to origin: ${prepared.branchName}`, 'git');
1252
1295
  console.log(`[executor] Task ${task.id}: branch pushed (${prepared.branchName})`);
1253
1296
  }
1254
1297
  else {
1255
1298
  result.deliveryStatus = 'skipped';
1256
- stream.text?.(`[Astro] No changes to push\n`);
1299
+ stream.operational?.('No changes to push', 'git');
1257
1300
  console.log(`[executor] Task ${task.id}: no changes to push`);
1258
1301
  }
1259
1302
  }
1260
1303
  else {
1261
1304
  // 'pr' — push + create PR, auto-merge into project branch if applicable
1262
- stream.text?.(`[Astro] Creating pull request for branch ${prepared.branchName}...\n`);
1305
+ stream.operational?.(`Creating PR: ${prepared.branchName} ${prepared.baseBranch ?? 'main'}...`, 'git');
1263
1306
  this.wsClient.sendTaskStatus(task.id, 'running', 94, 'Rebasing before push...');
1264
1307
  // Pre-push rebase: if the target branch moved forward, rebase our task
1265
1308
  // branch so the PR will be cleanly mergeable. The branch hasn't been
@@ -1267,9 +1310,11 @@ export class TaskExecutor {
1267
1310
  if (prepared.baseBranch) {
1268
1311
  const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.baseBranch, true);
1269
1312
  if (preRebase.rebased) {
1313
+ stream.operational?.(`Rebased onto origin/${prepared.baseBranch}`, 'git');
1270
1314
  console.log(`[executor] Task ${task.id}: pre-push rebase onto origin/${prepared.baseBranch} succeeded`);
1271
1315
  }
1272
1316
  else if (!preRebase.skipped) {
1317
+ stream.operational?.('Rebase skipped (conflicts), proceeding without rebase', 'git');
1273
1318
  console.log(`[executor] Task ${task.id}: pre-push rebase had conflicts, proceeding without rebase`);
1274
1319
  }
1275
1320
  }
@@ -1284,6 +1329,7 @@ export class TaskExecutor {
1284
1329
  baseBranch: prepared.baseBranch,
1285
1330
  autoMerge: hasProjectBranch,
1286
1331
  commitBeforeSha: prepared.commitBeforeSha,
1332
+ gitRoot: prepared.gitRoot,
1287
1333
  });
1288
1334
  result.branchName = prResult.branchName;
1289
1335
  if (prResult.prUrl) {
@@ -1292,7 +1338,7 @@ export class TaskExecutor {
1292
1338
  result.commitBeforeSha = prResult.commitBeforeSha;
1293
1339
  result.commitAfterSha = prResult.commitAfterSha;
1294
1340
  keepBranch = true;
1295
- stream.text?.(`[Astro] Pull request created: ${prResult.prUrl}\n`);
1341
+ stream.operational?.(`PR created: ${prResult.prUrl}`, 'git');
1296
1342
  if (prResult.autoMergeFailed) {
1297
1343
  // PR was created but auto-merge failed (likely conflict).
1298
1344
  // If the adapter supports session resume, ask the agent to
@@ -1304,7 +1350,7 @@ export class TaskExecutor {
1304
1350
  : null;
1305
1351
  if (prTaskContext?.sessionId && this.isResumableAdapter(adapter) && hasProjectBranch && prepared.branchName && prepared.baseBranch && prResult.prNumber && prepared.gitRoot) {
1306
1352
  for (let attempt = 1; attempt <= MAX_PR_MERGE_ATTEMPTS; attempt++) {
1307
- stream.text?.(`[Astro] PR auto-merge failed — agent resolving conflict (attempt ${attempt})...\n`);
1353
+ stream.operational?.(`Auto-merge failed — agent resolving conflict (attempt ${attempt})...`, 'git');
1308
1354
  console.log(`[executor] Task ${task.id}: PR auto-merge failed (attempt ${attempt}), resuming ${adapter.name} to resolve`);
1309
1355
  this.wsClient.sendTaskStatus(task.id, 'running', 97, `PR merge conflict — agent resolving (attempt ${attempt})...`);
1310
1356
  // Resume agent session — agent rebases and force-pushes.
@@ -1318,11 +1364,14 @@ export class TaskExecutor {
1318
1364
  result.deliveryError = `PR created but auto-merge failed. Agent resolution failed: ${resumeErr instanceof Error ? resumeErr.message : resumeErr}`;
1319
1365
  break;
1320
1366
  }
1321
- // Retry the GitHub merge
1367
+ // Retry the GitHub merge.
1368
+ // Use gitRoot as cwd (always valid, guarded at L1518) instead of
1369
+ // worktree path which may have been deleted during resolution.
1322
1370
  this.wsClient.sendTaskStatus(task.id, 'running', 98, `Retrying PR merge (attempt ${attempt})...`);
1323
- const retryMerge = await mergePullRequest(prepared.workingDirectory, prResult.prNumber, {
1371
+ const retryMerge = await mergePullRequest(prepared.gitRoot, prResult.prNumber, {
1324
1372
  method: 'squash',
1325
1373
  deleteBranch: true,
1374
+ repoSlug: repoSlug ?? undefined,
1326
1375
  });
1327
1376
  if (retryMerge.ok) {
1328
1377
  result.commitAfterSha = await getRemoteBranchSha(prepared.gitRoot, prepared.baseBranch) ?? undefined;
@@ -1362,18 +1411,18 @@ export class TaskExecutor {
1362
1411
  result.deliveryStatus = 'failed';
1363
1412
  result.deliveryError = `PR delivery failed: ${prResult.error}`;
1364
1413
  keepBranch = prResult.pushed ?? false; // Keep branch if it was pushed
1365
- stream.text?.(`[Astro] PR delivery failed: ${prResult.error}\n`);
1414
+ stream.operational?.(`PR delivery failed: ${prResult.error}`, 'git');
1366
1415
  console.error(`[executor] Task ${task.id}: PR delivery failed: ${prResult.error}`);
1367
1416
  }
1368
1417
  else {
1369
- stream.text?.(`[Astro] No changes to deliver\n`);
1418
+ stream.operational?.('No changes to deliver', 'git');
1370
1419
  console.log(`[executor] Task ${task.id}: no changes to push`);
1371
1420
  }
1372
1421
  }
1373
1422
  }
1374
1423
  catch (prError) {
1375
1424
  const prMsg = prError instanceof Error ? prError.message : String(prError);
1376
- stream.text?.(`[Astro] Delivery failed: ${prMsg}\n`);
1425
+ stream.operational?.(`Failed: ${prMsg}`, 'delivery');
1377
1426
  console.error(`[executor] Task ${task.id}: delivery (${deliveryMode}) failed: ${prMsg}`);
1378
1427
  // Delivery failure — don't override execution status
1379
1428
  result.deliveryStatus = 'failed';
@@ -1430,14 +1479,13 @@ export class TaskExecutor {
1430
1479
  clearTimeout(hardCapTimeoutId);
1431
1480
  if (idleTimerId !== undefined)
1432
1481
  clearTimeout(idleTimerId);
1433
- taskHeartbeatPhase = 'cleaning up';
1434
1482
  // Always cleanup the local worktree directory to reclaim disk space
1435
1483
  // (node_modules alone is ~680MB per worktree). When keepBranch is true
1436
1484
  // (PR created or branch pushed), we preserve the git branch but still
1437
1485
  // remove the working copy — the branch lives on remote/local refs,
1438
1486
  // and re-execution will create a fresh worktree if needed.
1439
1487
  if (prepared.branchName) {
1440
- stream.text?.(`\n[Astro] Cleaning up worktree (branch: ${prepared.branchName})...\n`);
1488
+ stream.operational?.(`Cleaning up worktree (branch: ${prepared.branchName})...`, 'astro');
1441
1489
  }
1442
1490
  if (this.preserveWorktrees) {
1443
1491
  console.log(`[executor] Task ${task.id}: worktree preserved (debug mode)`);
@@ -1596,7 +1644,7 @@ export class TaskExecutor {
1596
1644
  projectBranch: task.projectBranch,
1597
1645
  stdout: stream.stdout,
1598
1646
  stderr: stream.stderr,
1599
- text: stream.text,
1647
+ operational: stream.operational,
1600
1648
  signal,
1601
1649
  });
1602
1650
  if (!worktree) {