Skip to content

Commit c4ba9b3

Browse files
authored
fix: bug fix (#41)
* fix: hampering the base_branch branch issue * ws: apply agent changes * fix: screenshot attachment feature * feat: add overall comment feature * fix: follow only works when we scrolled till bottom * fix: time passed during chatting * fix: open session launches new terminal window instead of blocking dashboard * fix: resume with overall comments * fix: update total isolation using prehook * fix: update git diff
1 parent 035e472 commit c4ba9b3

9 files changed

Lines changed: 462 additions & 123 deletions

File tree

src/cli/dashboard.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async function buildEntries(config: any, state: any): Promise<WorkstreamEntry[]>
8989
if (hasWorktree) {
9090
const [bi, ds, dirtyResult] = await Promise.all([
9191
getBranchInfo(branch),
92-
getDiffStats(branch),
92+
getDiffStats(branch, worktreePath),
9393
$`git -C ${worktreePath} status --porcelain`.quiet().catch(() => null),
9494
]);
9595
branchInfo = bi;
@@ -99,7 +99,7 @@ async function buildEntries(config: any, state: any): Promise<WorkstreamEntry[]>
9999

100100
const hasSession = !!state?.currentRun?.workstreams?.[def.name]?.sessionId;
101101
const commentsData = await loadComments(def.name);
102-
const commentCount = commentsData.comments.length;
102+
const commentCount = commentsData.comments.length + (commentsData.overallComment ? 1 : 0);
103103
const pendingPromptText = await loadPendingPrompt(def.name);
104104

105105
return {
@@ -115,6 +115,7 @@ async function buildEntries(config: any, state: any): Promise<WorkstreamEntry[]>
115115
hasPendingPrompt: !!pendingPromptText,
116116
pendingPromptText: pendingPromptText ?? undefined,
117117
isDirty,
118+
startedAt: state?.currentRun?.workstreams?.[def.name]?.startedAt,
118119
} as WorkstreamEntry;
119120
});
120121

@@ -191,35 +192,42 @@ async function actionOpenEditor(name: string, state: any, config: any, editorOpt
191192

192193
// ─── Action: Open Claude session (interactive) ───────────────────────────────
193194

194-
async function actionOpenSession(name: string, ws: WorkstreamState, state: ProjectState) {
195-
if (!ws.sessionId) {
196-
console.error("Error: no session ID captured for this workstream.");
197-
return;
198-
}
195+
async function actionOpenSession(name: string, ws: WorkstreamState, _state: ProjectState): Promise<boolean> {
196+
if (!ws.sessionId) return false;
199197

198+
// Open the Claude session in a new terminal window so it doesn't conflict
199+
// with the dashboard's TUI (alternate screen + raw mode). The session runs
200+
// independently, allowing the user to keep using the dashboard.
200201
const absWorktreePath = resolve(ws.worktreePath);
201-
const proc = Bun.spawn(["claude", "--dangerously-skip-permissions", "--resume", ws.sessionId], {
202-
cwd: absWorktreePath,
203-
stdin: "inherit",
204-
stdout: "inherit",
205-
stderr: "inherit",
206-
});
207-
208-
const exitCode = await proc.exited;
209-
console.log(`\nReturned from Claude session for "${name}".`);
210-
211-
const { $ } = await import("bun");
212-
const gitStatus = await $`git -C ${absWorktreePath} status --porcelain`.quiet().catch(() => null);
213-
const changes = gitStatus?.stdout.toString().trim();
214-
if (changes) {
215-
await $`git -C ${absWorktreePath} add -A`.quiet().catch(() => {});
216-
await $`git -C ${absWorktreePath} commit -m "ws: apply agent changes"`.quiet().catch(() => {});
202+
const shellCmd = `cd ${shellEscape(absWorktreePath)} && claude --resume ${shellEscape(ws.sessionId)}`;
203+
204+
if (process.platform === "darwin") {
205+
// Use AppleScript to open a new Terminal.app window with the resume command
206+
const script = `tell application "Terminal"
207+
activate
208+
do script "${shellCmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
209+
end tell`;
210+
const proc = Bun.spawn(["osascript", "-e", script], { stdio: ["ignore", "pipe", "ignore"] });
211+
await proc.exited;
212+
return proc.exitCode === 0;
213+
} else {
214+
// Try common Linux terminal emulators
215+
const terminals = [
216+
["gnome-terminal", "--", "bash", "-c", shellCmd],
217+
["xterm", "-e", shellCmd],
218+
];
219+
for (const args of terminals) {
220+
try {
221+
Bun.spawn(args, { stdio: ["ignore", "ignore", "ignore"] });
222+
return true;
223+
} catch {}
224+
}
225+
return false;
217226
}
227+
}
218228

219-
ws.status = exitCode === 0 ? "success" : "failed";
220-
ws.finishedAt = new Date().toISOString();
221-
await appendWorkstreamStatus(ws);
222-
console.log(`Status updated to: ${ws.status}`);
229+
function shellEscape(s: string): string {
230+
return "'" + s.replace(/'/g, "'\\''") + "'";
223231
}
224232

225233
// ─── Action: View diff ───────────────────────────────────────────────────────
@@ -252,6 +260,7 @@ async function actionViewLogs(name: string, state: any) {
252260
name,
253261
logFile: ws.logFile,
254262
status: ws.status,
263+
startedAt: ws.startedAt,
255264
});
256265
}
257266

@@ -336,7 +345,7 @@ async function dispatchAction(action: DashboardAction, state: any, config: any):
336345
case "open-session": {
337346
const ws = state.currentRun?.workstreams?.[action.name];
338347
if (ws) await actionOpenSession(action.name, ws, state);
339-
return false;
348+
return true;
340349
}
341350

342351
case "run": {
@@ -448,6 +457,12 @@ Dashboard keys: Enter=editor, d=diff, r=resume session, p=prompt agent,
448457
await openEditor(absPath, resolved);
449458
return true;
450459
},
460+
onOpenSession: async (name: string): Promise<boolean> => {
461+
const s = await loadState() ?? freshState;
462+
const ws = s.currentRun?.workstreams?.[name];
463+
if (!ws) return false;
464+
return actionOpenSession(name, ws, s);
465+
},
451466
onCreateWorkstream: async (name: string): Promise<boolean> => {
452467
try {
453468
await actionCreateWorkstream(name);

src/cli/diff.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ Interactive viewer keys: j/k scroll, Tab switch panels, t toggle side-by-side,
3939
process.exit(1);
4040
}
4141
try {
42-
const diff = await wt.diffBranch(`ws/${n}`);
42+
const [branchDiff, uncommittedDiff] = await Promise.all([
43+
wt.diffBranch(`ws/${n}`),
44+
wt.diff(n),
45+
]);
46+
const diff = branchDiff + uncommittedDiff;
4347
await openDiffViewer(n, diff);
4448
} catch (e: any) {
4549
console.error(`Error: ${e.message}`);
@@ -59,7 +63,11 @@ Interactive viewer keys: j/k scroll, Tab switch panels, t toggle side-by-side,
5963
if (ws.status !== "success" && ws.status !== "running") continue;
6064

6165
try {
62-
const diff = await wt.diffBranch(`ws/${n}`);
66+
const [branchDiff, uncommittedDiff] = await Promise.all([
67+
wt.diffBranch(`ws/${n}`),
68+
wt.diff(n),
69+
]);
70+
const diff = branchDiff + uncommittedDiff;
6371
if (diff.trim()) {
6472
console.log(`\x1b[1m=== ${n} ===\x1b[0m`);
6573
console.log(diff);

src/cli/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export async function listAction(configPath: string = "workstream.yaml") {
6363
}
6464

6565
const commentsData = await loadComments(def.name);
66-
const commentCount = commentsData.comments.length;
66+
const commentCount = commentsData.comments.length + (commentsData.overallComment ? 1 : 0);
6767
const commentsStr = commentCount > 0
6868
? `${A.brightYellow}${commentCount} comment${commentCount > 1 ? "s" : ""}${A.reset}`
6969
: "";
@@ -72,7 +72,7 @@ export async function listAction(configPath: string = "workstream.yaml") {
7272
if (hasWorktree) {
7373
const [info, stats] = await Promise.all([
7474
getBranchInfo(branch),
75-
getDiffStats(branch),
75+
getDiffStats(branch, worktreePath),
7676
]);
7777

7878
const syncPlain = info.ahead || info.behind ? `↑${info.ahead}${info.behind}` : "·";

src/core/agent.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -152,30 +152,9 @@ export class AgentAdapter {
152152

153153
const exitCode = await proc.exited;
154154

155-
// Auto-commit any changes the agent made
156-
if (exitCode === 0) {
157-
await this.autoCommit(absWorkDir, appendLog);
158-
}
159-
160155
return { exitCode, sessionId };
161156
} catch (e: any) {
162157
throw new AgentError(`Agent failed: ${e.message}`);
163158
}
164159
}
165-
166-
private async autoCommit(
167-
workDir: string,
168-
appendLog: (data: string | Uint8Array) => Promise<void>,
169-
): Promise<void> {
170-
const { $ } = await import("bun");
171-
172-
const status = await $`git -C ${workDir} status --porcelain`.quiet();
173-
const changes = status.stdout.toString().trim();
174-
if (!changes) return;
175-
176-
try {
177-
await $`git -C ${workDir} add -A`.quiet();
178-
await $`git -C ${workDir} commit -m "ws: apply agent changes"`.quiet();
179-
} catch {}
180-
}
181160
}

src/core/comments.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface ReviewComment {
1717
export interface WorkstreamComments {
1818
workstream: string;
1919
comments: ReviewComment[];
20+
overallComment?: string;
2021
updatedAt: string;
2122
}
2223

@@ -56,31 +57,44 @@ export async function deleteComment(name: string, index: number): Promise<Workst
5657
}
5758

5859
export function formatCommentsAsPrompt(data: WorkstreamComments): string {
59-
if (data.comments.length === 0) return "";
60-
61-
const sections = data.comments.map((c, i) => {
62-
const typeLabel = c.lineType === "add" ? "added" : c.lineType === "remove" ? "removed" : "unchanged";
63-
const loc = c.line
64-
? `${c.filePath}:${c.line} (${typeLabel} line, ${c.side ?? "new"} side)`
65-
: c.filePath;
66-
67-
const parts = [`### Comment ${i + 1}: ${loc}`, c.text];
68-
69-
if (c.diffContext) {
70-
parts.push("Surrounding diff context:");
71-
parts.push("```diff", c.diffContext, "```");
72-
} else if (c.lineContent) {
73-
parts.push(`Line: \`${c.lineContent}\``);
74-
}
75-
76-
return parts.join("\n");
77-
});
78-
79-
return [
80-
"I have the following review comments on the changes you made. Each comment includes the file, line number, whether the line was added/removed/unchanged, and the surrounding diff for context.",
81-
"",
82-
...sections,
83-
"",
84-
"Please address each comment. Use the diff context to understand what changed and apply the fix to the correct location in the current working tree.",
85-
].join("\n");
60+
const hasComments = data.comments.length > 0;
61+
const hasOverall = !!data.overallComment;
62+
63+
if (!hasComments && !hasOverall) return "";
64+
65+
const parts: string[] = [];
66+
67+
if (hasOverall) {
68+
parts.push("## Overall Comment", "", data.overallComment!, "");
69+
}
70+
71+
if (hasComments) {
72+
const sections = data.comments.map((c, i) => {
73+
const typeLabel = c.lineType === "add" ? "added" : c.lineType === "remove" ? "removed" : "unchanged";
74+
const loc = c.line
75+
? `${c.filePath}:${c.line} (${typeLabel} line, ${c.side ?? "new"} side)`
76+
: c.filePath;
77+
78+
const inner = [`### Comment ${i + 1}: ${loc}`, c.text];
79+
80+
if (c.diffContext) {
81+
inner.push("Surrounding diff context:");
82+
inner.push("```diff", c.diffContext, "```");
83+
} else if (c.lineContent) {
84+
inner.push(`Line: \`${c.lineContent}\``);
85+
}
86+
87+
return inner.join("\n");
88+
});
89+
90+
parts.push(
91+
"I have the following review comments on the changes you made. Each comment includes the file, line number, whether the line was added/removed/unchanged, and the surrounding diff for context.",
92+
"",
93+
...sections,
94+
"",
95+
"Please address each comment. Use the diff context to understand what changed and apply the fix to the correct location in the current working tree.",
96+
);
97+
}
98+
99+
return parts.join("\n");
86100
}

src/core/worktree.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,71 @@ export class WorktreeManager {
3131
`Failed to create worktree for "${name}": ${e.stderr?.toString() ?? e.message}`
3232
);
3333
}
34+
35+
// Write a Claude Code pre-tool hook into the worktree that blocks any
36+
// Read/Edit/Write targeting files outside this worktree. This prevents
37+
// agents (and interactive Claude sessions) from accidentally modifying the
38+
// main repo or other worktrees.
39+
await this.writeWorktreeGuard(path);
40+
3441
return path;
3542
}
3643

44+
private async writeWorktreeGuard(worktreePath: string): Promise<void> {
45+
const { mkdir, writeFile, chmod } = await import("fs/promises");
46+
const { resolve } = await import("path");
47+
const absPath = resolve(worktreePath);
48+
49+
// Guard script: receives tool input JSON on stdin, rejects file paths outside worktree
50+
const guardScript = `#!/bin/bash
51+
# Auto-generated by ws — blocks file access outside this worktree
52+
WORKTREE="${absPath}"
53+
INPUT=$(cat)
54+
FILE_PATH=$(echo "$INPUT" | grep -o '"file_path":"[^"]*"' | head -1 | sed 's/"file_path":"\\(.*\\)"/\\1/')
55+
if [ -z "$FILE_PATH" ]; then
56+
exit 0
57+
fi
58+
# Resolve the path
59+
RESOLVED=$(cd "$WORKTREE" && realpath -m "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
60+
case "$RESOLVED" in
61+
"$WORKTREE"/*)
62+
exit 0
63+
;;
64+
*)
65+
echo "BLOCKED: file_path \\"$FILE_PATH\\" is outside this worktree ($WORKTREE). Use relative paths or paths under the worktree." >&2
66+
exit 2
67+
;;
68+
esac
69+
`;
70+
71+
const claudeDir = `${worktreePath}/.claude`;
72+
await mkdir(claudeDir, { recursive: true });
73+
const scriptPath = `${claudeDir}/worktree-guard.sh`;
74+
await writeFile(scriptPath, guardScript);
75+
await chmod(scriptPath, 0o755);
76+
77+
const settings = {
78+
hooks: {
79+
PreToolUse: [
80+
{
81+
matcher: "Edit|Write|Read",
82+
hooks: [
83+
{
84+
type: "command",
85+
command: `${resolve(scriptPath)}`,
86+
},
87+
],
88+
},
89+
],
90+
},
91+
};
92+
93+
await writeFile(
94+
`${claudeDir}/settings.local.json`,
95+
JSON.stringify(settings, null, 2) + "\n",
96+
);
97+
}
98+
3799
async remove(name: string): Promise<void> {
38100
const path = `${TREES_DIR}/${name}`;
39101
const branch = `ws/${name}`;
@@ -67,7 +129,24 @@ export class WorktreeManager {
67129
async diff(name: string): Promise<string> {
68130
const path = `${TREES_DIR}/${name}`;
69131
const result = await $`git -C ${path} diff HEAD`.quiet();
70-
return result.stdout.toString();
132+
let diff = result.stdout.toString();
133+
134+
// Include untracked (new) files — git diff HEAD only shows tracked changes
135+
const untracked = await $`git -C ${path} ls-files --others --exclude-standard`.quiet().catch(() => null);
136+
if (untracked) {
137+
const files = untracked.stdout.toString().trim().split("\n")
138+
.filter(f => f && !f.startsWith(".claude/"));
139+
for (const file of files) {
140+
try {
141+
await $`git -C ${path} diff --no-index -- /dev/null ${file}`.quiet();
142+
} catch (e: any) {
143+
// git diff --no-index exits 1 when files differ — expected for new files
144+
if (e.stdout) diff += e.stdout.toString();
145+
}
146+
}
147+
}
148+
149+
return diff;
71150
}
72151

73152
async diffBranch(branch: string, baseBranch?: string): Promise<string> {

0 commit comments

Comments
 (0)