Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions coverage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@
ℹ yaml | | | |
ℹ index.js | 100.00 | 94.44 | 100.00 |
ℹ tui | | | |
ℹ app.js | 44.22 | 55.81 | 20.00 | 130 141 151-157 167-177 185-189 197-200 221-246 253-262 271-296 312-330 340-345 352-353 360-405 409-531 544-548 551-561 564-577 593-598 600-604 606-610 612-619 621-627 629-635 642-652 659-700 725-726
ℹ app.js | 45.32 | 57.78 | 22.22 | 149 160 170-176 186-197 205-210 218-221 242-267 274-283 292-317 333-351 361-369 376-377 384-429 433-555 568-572 575-585 588-601 617-622 624-628 630-634 636-643 645-651 653-659 666-676 683-724 749-750
ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35
ℹ commandHelp.js | 100.00 | 100.00 | 100.00 |
ℹ commandParser.js | 97.56 | 89.87 | 100.00 | 90-95
Expand Down Expand Up @@ -231,6 +231,6 @@
ℹ loadAgents.js | 88.00 | 42.86 | 100.00 | 17-18 32-33 38-39
ℹ project.js | 100.00 | 100.00 | 100.00 |
ℹ -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ all files | 91.52 | 87.50 | 86.86 |
ℹ all files | 91.51 | 87.50 | 86.87 |
ℹ -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ end of coverage report
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-01
59 changes: 59 additions & 0 deletions openspec/changes/archive/2026-11-01-tui-project-cwd/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Design

## Context

`src/tui/app.js` holds three `useCallback` handlers — `handleSelectProject`,
`handleClearProject`, `handleNewSession` — that update the in-memory active-project state
via `setActiveProject`/`setProjectCwd` but never move the OS process cwd. Consumers that
read `process.cwd()` directly (the cron command builders in `src/scheduler/cron.js` and
`src/tools/cron/index.js`, which embed `cd ${process.cwd()}`) therefore run in the wrong
directory. See proposal.md for motivation.

The structured logger is imported as `import { logger } from "../shared/logger.js"` and
exposes `logger.error` (the established pattern in `src/session/shutdown.js` and
`src/tui/conversationArea.js`). `config` is already a prop of `App` and already a
dependency of `handleClearProject`.

## Goals / Non-Goals

**Goals:**
- Move the process cwd into the selected project on select.
- Restore the process cwd to `config.cwd` (fallback `process.cwd()`) on clear and on new
session.
- Guard every `process.chdir` with try/catch + `logger.error` (AGENTS.md 1.1).

**Non-Goals:**
- Changing `src/workspace/loadAgents.js`'s module-load `process.cwd()` snapshot.
- Changing the active-project store or cron builder logic.

## Decisions

- **Guard shape.** Wrap each `process.chdir` in a `try { process.chdir(target); }
catch (err) { logger.error(...); }`. Alternatives considered: a shared helper
`safeChdir(target)` to avoid repetition. Given only three call sites and the KISS
principle (AGENTS.md 1.4), a small inline guard is acceptable; a helper is preferred if
the three blocks exceed three lines of duplicated logic. Decision: extract a tiny
`safeChdir` closure to satisfy DRY without over-engineering.
- **Restore target.** `config.cwd || process.cwd()`. `config.cwd` is the madz root
captured at load; the `process.cwd()` fallback covers the unset case.
- **`handleNewSession` deps.** Add `config` to its `useCallback` dependency array so the
restore target is current. `handleSelectProject` takes `projectPath` as an argument, so
its deps stay `[]`.

## Risks / Trade-offs

- [chdir mutates global process state, affecting the whole process] → This is the
intended behavior; the TUI is a single-process app. Tests must save/restore the original
cwd to avoid leaking state across test cases.
- [An invalid project path throws] → Guarded by try/catch + logger; the TUI continues.
- [The `loadAgents.js` snapshot does not track runtime chdir] → Explicitly out of scope;
documented in the proposal and spec.

## Migration Plan

No data migration. Deploy by merging the change. Rollback is a simple revert of
`src/tui/app.js`.

## Open Questions

None.
46 changes: 46 additions & 0 deletions openspec/changes/archive/2026-11-01-tui-project-cwd/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Proposal

## Why

The TUI's `/projects` panel updates in-memory active-project state but never moves the
OS process working directory. `handleSelectProject`, `handleClearProject`, and
`handleNewSession` in `src/tui/app.js` call `setActiveProject`/`setProjectCwd` but omit
`process.chdir`, so consumers that read `process.cwd()` directly — the cron command
builders in `src/scheduler/cron.js` and `src/tools/cron/index.js` (which embed
`cd ${process.cwd()}`) — run in the wrong directory. This change wires the process cwd
to the active project.

## What Changes

- `handleSelectProject` moves the process into the selected project via a guarded
`process.chdir(projectPath)`.
- `handleClearProject` restores the process cwd to `config.cwd` (fallback
`process.cwd()`) via a guarded `process.chdir`.
- `handleNewSession` restores the process cwd to `config.cwd` (fallback
`process.cwd()`) for consistency, and adds `config` to its `useCallback` deps.
- Every `process.chdir` call is wrapped in try/catch that records failures through the
structured logger (`logger.error`), per AGENTS.md 1.1 — no silent catch, no crash.

## Capabilities

### New Capabilities
- `tui-project-cwd`: The TUI moves the OS process working directory in lockstep with the
active project — into the project on select, back to `config.cwd` on clear and on new
session — with guarded, logged `process.chdir` calls.

### Modified Capabilities
<!\-\- none \-\->

## Non-goals

- Changing `src/workspace/loadAgents.js`'s module-load `process.cwd()` snapshot semantics
(a separate concern; callers must pass `targetCwd` explicitly).
- Altering the active-project in-memory store (`src/workspace/project.js`).
- Changing cron command builder logic beyond the fact that they now read a correct cwd.

## Impact

- `src/tui/app.js` — three handlers gain guarded `process.chdir` calls; `logger` import
added; `handleNewSession` deps updated.
- `tests/unit/tui/app.test.js` — regression tests for select/clear/invalid-path.
- No API, dependency, or config changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Spec Delta

## Purpose

Keeps the OS process working directory in lockstep with the TUI's active project, so
that consumers reading `process.cwd()` (e.g. cron command builders) operate in the
directory the user selected, and restore it to the configured root when the project is
cleared or a new session begins.

## ADDED Requirements

### Requirement: Process cwd moves into the selected project
The TUI SHALL move the OS process working directory into the selected project directory
when the user selects a project, in addition to updating the in-memory active-project
state.

#### Scenario: Selecting a valid project
- **WHEN** the user selects a project whose directory exists
- **THEN** the process working directory becomes that project directory
- **AND** the in-memory active-project state reflects the same directory

#### Scenario: Selecting an invalid project path
- **WHEN** the user selects a project whose directory does not exist or cannot be entered
- **THEN** the process working directory is left unchanged
- **AND** the failure is recorded through the structured logger
- **AND** no exception propagates to crash the TUI

### Requirement: Process cwd restores on project clear
The TUI SHALL restore the OS process working directory to the configured root
(`config.cwd`, falling back to the current process cwd) when the user clears the active
project.

#### Scenario: Clearing the active project
- **WHEN** the user clears the active project
- **THEN** the process working directory is restored to `config.cwd` when it is set
- **AND** the in-memory active-project state is cleared

#### Scenario: Clearing with no configured root
- **WHEN** the user clears the active project and `config.cwd` is unset
- **THEN** the process working directory is restored to the current process cwd

### Requirement: Process cwd restores on new session
The TUI SHALL restore the OS process working directory to the configured root when a new
session begins, consistent with clearing the active project.

#### Scenario: Starting a new session
- **WHEN** the user starts a new session
- **THEN** the process working directory is restored to `config.cwd` when it is set
- **AND** the in-memory active-project state is cleared

### Requirement: Chdir failures are guarded and logged
Every process-working-directory change SHALL be guarded so that a failure to change
directory is recorded through the structured logger and never crashes the TUI.

#### Scenario: Chdir throws on an invalid target
- **WHEN** a process-working-directory change fails
- **THEN** the error is recorded through the structured logger
- **AND** the TUI continues to run
30 changes: 30 additions & 0 deletions openspec/changes/archive/2026-11-01-tui-project-cwd/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Tasks

## 1. Guarded chdir helper

- [x] 1.1 Import `logger` from `../shared/logger.js` in `src/tui/app.js`.
- [x] 1.2 Add a small `safeChdir(target)` guard that wraps `process.chdir(target)` in
try/catch and records failures via `logger.error` (no silent catch, no crash).

## 2. Wire handlers

- [x] 2.1 In `handleSelectProject`, call `safeChdir(projectPath)` after
`setProjectCwd(projectPath)`.
- [x] 2.2 In `handleClearProject`, call `safeChdir(config.cwd || process.cwd())` after
`setProjectCwd("")`.
- [x] 2.3 In `handleNewSession`, call `safeChdir(config.cwd || process.cwd())` and add
`config` to its `useCallback` dependency array.

## 3. Regression tests

- [x] 3.1 In `tests/unit/tui/app.test.js`, add a test asserting `process.cwd()` reflects
the selected project after `handleSelectProject` (save/restore the original cwd).
- [x] 3.2 Add a test asserting `process.cwd()` restores to `config.cwd` after
`handleClearProject`.
- [x] 3.3 Add a test for the invalid-path case: `process.chdir` throws, the logger records
it, and no exception propagates.

## 4. Verify

- [x] 4.1 Run `npm run test` and confirm all tests pass.
- [x] 4.2 Run `npm run coverage` and confirm coverage is maintained.
57 changes: 57 additions & 0 deletions openspec/specs/tui-project-cwd/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# tui-project-cwd Specification

## Purpose
Keeps the OS process working directory in lockstep with the TUI's active project, so
that consumers reading `process.cwd()` (e.g. cron command builders) operate in the
directory the user selected, and restore it to the configured root when the project is
cleared or a new session begins.

## Requirements

### Requirement: Process cwd moves into the selected project
The TUI SHALL move the OS process working directory into the selected project directory
when the user selects a project, in addition to updating the in-memory active-project
state.

#### Scenario: Selecting a valid project
- **WHEN** the user selects a project whose directory exists
- **THEN** the process working directory becomes that project directory
- **AND** the in-memory active-project state reflects the same directory

#### Scenario: Selecting an invalid project path
- **WHEN** the user selects a project whose directory does not exist or cannot be entered
- **THEN** the process working directory is left unchanged
- **AND** the failure is recorded through the structured logger
- **AND** no exception propagates to crash the TUI

### Requirement: Process cwd restores on project clear
The TUI SHALL restore the OS process working directory to the configured root
(`config.cwd`, falling back to the current process cwd) when the user clears the active
project.

#### Scenario: Clearing the active project
- **WHEN** the user clears the active project
- **THEN** the process working directory is restored to `config.cwd` when it is set
- **AND** the in-memory active-project state is cleared

#### Scenario: Clearing with no configured root
- **WHEN** the user clears the active project and `config.cwd` is unset
- **THEN** the process working directory is restored to the current process cwd

### Requirement: Process cwd restores on new session
The TUI SHALL restore the OS process working directory to the configured root when a new
session begins, consistent with clearing the active project.

#### Scenario: Starting a new session
- **WHEN** the user starts a new session
- **THEN** the process working directory is restored to `config.cwd` when it is set
- **AND** the in-memory active-project state is cleared

### Requirement: Chdir failures are guarded and logged
Every process-working-directory change SHALL be guarded so that a failure to change
directory is recorded through the structured logger and never crashes the TUI.

#### Scenario: Chdir throws on an invalid target
- **WHEN** a process-working-directory change fails
- **THEN** the error is recorded through the structured logger
- **AND** the TUI continues to run
26 changes: 25 additions & 1 deletion src/tui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,30 @@ import { ProjectsPanel } from "./projectsPanel.js";
import { SubagentsPanel } from "./subagentsPanel.js";
import { updateSubagentStore } from "./subagentStore.js";
import { setProjectCwd } from "../workspace/project.js";
import { logger } from "../shared/logger.js";
import { getActiveProviderConfig } from "../provider/index.js";
import { useMouseScroll, scaleScrollDelta } from "./useMouseScroll.js";
import { buildLayout, extractSelection, mapCoordToChar } from "./selectionLayout.js";
import clipboardy from "clipboardy";

/**
* Change the process working directory, guarding against an invalid target.
*
* `process.chdir` throws when the target path does not exist or cannot be
* entered; we record the failure through the structured logger rather than
* letting it propagate and crash the TUI (AGENTS.md 1.1 — no silent catch).
*
* @param {string} target - The directory to change into
* @returns {void}
*/
export function safeChdir(target) {
try {
process.chdir(target);
} catch (err) {
logger.error(`[tui] failed to change cwd to ${target}: ${err.message}`);
}
}

/**
* App router — holds cross-cutting state and view routing.
* Renders ConversationArea + InputArea in conversation view,
Expand Down Expand Up @@ -166,6 +185,7 @@ function App({
const handleSelectProject = useCallback((projectPath) => {
setActiveProject(projectPath);
setProjectCwd(projectPath);
safeChdir(projectPath);
setCurrentView(PANELS.CONVERSATION);
// Silently notify the agent that we're now working in the selected project.
// The conversation area is unmounted during the panel view, so stage the
Expand All @@ -184,6 +204,7 @@ function App({
const handleClearProject = useCallback(() => {
setActiveProject("");
setProjectCwd("");
safeChdir(config.cwd || process.cwd());
setCurrentView(PANELS.CONVERSATION);
// Silently notify the agent that we've returned to the root directory.
pendingSilentMessageRef.current = "We are now working in madz root directory.";
Expand Down Expand Up @@ -343,7 +364,10 @@ function App({
// cleared. A new session has no project context, so leaving the prior
// selection in place would falsely imply one is active.
setActiveProject("");
}, []);
// Restore the process cwd to the configured root, consistent with
// clearing the active project.
safeChdir(config.cwd || process.cwd());
}, [config]);

/**
* handleQuit — App-level exit.
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/tui/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,61 @@ describe("App — in-conversation search (Ctrl+F)", () => {
assert.ok(mod.default, "InputArea default export exists");
});
});

describe("safeChdir — process cwd wiring", () => {
it("moves the process cwd into a valid project directory", async () => {
const mod = await import("../../../src/tui/app.js");
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const original = process.cwd();
try {
const target = mkdtempSync(join(tmpdir(), "madz-cwd-"));
mod.safeChdir(target);
assert.strictEqual(process.cwd(), target);
} finally {
process.chdir(original);
}
});

it("restores the process cwd to the configured root on clear", async () => {
const mod = await import("../../../src/tui/app.js");
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const original = process.cwd();
try {
const root = mkdtempSync(join(tmpdir(), "madz-root-"));
const project = mkdtempSync(join(tmpdir(), "madz-proj-"));
// Simulate select: move into the project.
mod.safeChdir(project);
assert.strictEqual(process.cwd(), project);
// Simulate clear: restore to config.cwd.
mod.safeChdir(root);
assert.strictEqual(process.cwd(), root);
} finally {
process.chdir(original);
}
});

it("logs and does not crash when the target path is invalid", async () => {
const mod = await import("../../../src/tui/app.js");
const { logger } = await import("../../../src/shared/logger.js");
const original = process.cwd();
const originalError = logger.error;
let logged = null;
logger.error = (msg) => {
logged = msg;
};
try {
// An invalid target throws inside process.chdir; safeChdir must
// swallow it, record it via the logger, and leave the cwd intact.
mod.safeChdir("/nonexistent/path/that/does/not/exist");
assert.strictEqual(process.cwd(), original);
assert.ok(logged, "logger.error was called");
assert.match(logged, /failed to change cwd/);
} finally {
logger.error = originalError;
}
});
});
Loading