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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Work and reliability

- Show an animated `Naming lane…` placeholder while automatic lane identity is still resolving, reveal the deterministic fallback only on failure, refresh renamed branches live across Lanes, Git Actions, and session hover details, and keep sidebar Working durations anchored to each chat turn.
- Prefer explicit environment and GitHub CLI credentials before a stored PAT, keep the GitHub App read-only and dedicated to real-time PR updates, avoid false classic OAuth scope errors for App authorization, and render App rate limits as a concise cooldown instead of a raw relay error.
- Clear an explicit `Needs you` hand-raise when the user replies during an active turn, keep agent-to-agent steers from dismissing it, and give bundled agents concrete `note` / `ask` / snooze lifecycle rules so blocked idle chats do not appear Done.
- Keep agent-authored Work status lines glanceable by normalizing them to 3–6 words and at most 72 characters while preserving full blocking questions in `ade chat ask`.

## [1.2.46] - 2026-07-30

### GitHub stacked pull requests
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ ade chat create --lane lane-id --provider codex --no-parent # spawned chats de
ade chat read session-id --limit 20 --text
ade chat message session-id --kind auto --text "status/context"
ade chat steer session-id --text "active-turn context"
ade chat note "running e2e shard 2/4" # update the caller's Work sidebar status; add --session <id> to target explicitly
ade chat note "testing desktop auth fallback" # update Work status (3–6 words, max 72 characters); add --session <id> to target explicitly
ade chat ask "Which account should I use?" # escalate a blocking question; add --session <id> to target explicitly
ade session show session-id --text # settle/snooze state, and why a snoozed row came back
ade session snooze session-id --for 1h # 30m|1h|4h|1d|1.5h; a bare number means minutes; relative durations cap at 30d
Expand Down
21 changes: 11 additions & 10 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2229,17 +2229,17 @@ describe("adeRpcServer", () => {
// it ends with the user prompt and carries the inline guidance preamble.
const createCall = (fixture.runtime.ptyService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as { args: string[] };
const finalArg = createCall.args[createCall.args.length - 1];
expect(finalArg).toContain("control plane for ADE state");
expect(finalArg).toContain("proof & screenshots");
expect(finalArg).toContain("clean up processes you start");
expect(finalArg).toContain("CLI controls ADE state");
expect(finalArg).toContain("PRs, proof, apps");
expect(finalArg).toContain("clean up started processes");
expect(finalArg).toContain("ade chat note");
expect(finalArg).toContain("ade chat ask");
expect(finalArg).toContain("You cannot settle or unsettle a session");
expect(finalArg.endsWith("Implement API wiring")).toBe(true);
expect(response.structuredContent.startupCommand).toContain("claude");
expect(response.structuredContent.startupCommand).toContain("--model");
expect(response.structuredContent.startupCommand).toContain("--permission-mode");
expect(response.structuredContent.startupCommand).toContain("control plane for ADE state");
expect(response.structuredContent.startupCommand).toContain("CLI controls ADE state");
expect(response.structuredContent.permissionMode).toBe("default");
expect(response.structuredContent.contextRef?.path).toBeNull();
});
Expand Down Expand Up @@ -2329,11 +2329,12 @@ describe("adeRpcServer", () => {
}),
);
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall?.env).not.toHaveProperty(ADE_BUNDLED_AGENT_SKILLS_DIR_ENV);
expect(createCall?.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\"", "-c", "service_tier=\"default\""]));
expect(createCall?.args).not.toContain(expect.stringContaining("fix failing tests"));
expect(createCall?.initialInput).toContain("fix failing tests");
expect(createCall?.initialInputDelayMs).toBe(750);
expect(createCall).toBeDefined();
expect(createCall!.env).not.toHaveProperty(ADE_BUNDLED_AGENT_SKILLS_DIR_ENV);
expect(createCall!.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\"", "-c", "service_tier=\"default\""]));
expect(createCall!.args).not.toContain(expect.stringContaining("fix failing tests"));
expect(createCall!.initialInput).toContain("fix failing tests");
expect(createCall!.initialInputDelayMs).toBe(750);
expect(fixture.runtime.ptyService.writeBySessionId).not.toHaveBeenCalled();
expect(fixture.runtime.sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({
sessionId: "session-1",
Expand Down Expand Up @@ -2881,7 +2882,7 @@ describe("adeRpcServer", () => {
expect(response.structuredContent.permissionMode).toBe("plan");
expect(response.structuredContent.startupCommand).toContain("--sandbox");
expect(response.structuredContent.startupCommand).toContain("read-only");
expect(response.structuredContent.startupCommand).toContain("control plane for ADE state");
expect(response.structuredContent.startupCommand).toContain("CLI controls ADE state");
const contextPath = response.structuredContent.contextRef?.path as string | null;
expect(contextPath).toBeTruthy();
expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true);
Expand Down
5 changes: 4 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,9 @@ const HELP_BY_COMMAND: Record<string, string> = {
- login keeps one connection open for the whole device flow because the
device-auth session lives in runtime memory; do not split start and poll
across separate invocations in headless mode.
- GitHub operations prefer an explicit environment token, then GitHub CLI,
and finally a stored PAT. The GitHub App remains read-only and is used
only for webhook-backed PR updates.

Flags (login):
--max-wait <seconds> Give up waiting after N seconds (default: GitHub's
Expand Down Expand Up @@ -1715,7 +1718,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat create --lane <lane> --provider claude --model anthropic/claude-opus-5 --prompt "fix the tests"
$ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json)
$ ade chat send <session> --text "next step" Send a message; steers automatically if the turn is active
$ ade chat note "running e2e shard 2/4" Update this session's Work sidebar status line
$ ade chat note "testing desktop auth fallback" # Update the Work status line (3–6 words, max 72 characters)
$ ade chat ask "Which account should I use?" Escalate a blocking question to the user
'note' and 'ask' default to the caller and accept --session <id>.
'chat settle' / 'chat unsettle' were removed: only the user (or a
Expand Down
174 changes: 169 additions & 5 deletions apps/ade-cli/src/headlessLinearServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ describe("headlessLinearServices", () => {
await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({
authSource: "environment",
connected: true,
patTokenStored: true,
userLogin: "octocat",
});
expect(fetchImpl).toHaveBeenCalledWith(
Expand All @@ -669,12 +670,99 @@ describe("headlessLinearServices", () => {
}
});

it("uses GitHub App authorization before a stored machine PAT for async REST calls", async () => {
it("keeps the read-only GitHub App out of operational REST credential selection", async () => {
const previousAdeHome = process.env.ADE_HOME;
const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN;
const previousGitHubToken = process.env.GITHUB_TOKEN;
const previousGhToken = process.env.GH_TOKEN;
const previousGhConfigDir = process.env.GH_CONFIG_DIR;
const previousFetch = globalThis.fetch;
process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-"));
process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-config-"));
delete process.env.ADE_GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const machineCredentialStore = new EncryptedFileCredentialStore();
machineCredentialStore.setSync("github.token.v1", "ghp_stale_stored_token");
machineCredentialStore.setSync("github.token.v1", "ghp_stored_token");
Comment thread
arul28 marked this conversation as resolved.
machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({
accessToken: "ghu_app_user_token",
tokenType: "bearer",
scope: null,
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
refreshToken: null,
refreshTokenExpiresAt: null,
userLogin: "octocat",
updatedAt: new Date().toISOString(),
}));
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const authorization = new Headers(init?.headers).get("authorization");
if (authorization !== "Bearer ghp_stored_token") {
return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 });
}
return new Response(JSON.stringify({ login: "octocat" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-oauth-scopes": "repo, workflow",
},
});
}) as unknown as typeof fetch;
globalThis.fetch = fetchImpl;

const githubService = createHeadlessGitHubService(
"/tmp/ade-project",
{ debug() {}, info() {}, warn() {}, error() {} } as any,
);
try {
await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({
authSource: "pat",
connected: true,
patTokenStored: true,
userLogin: "octocat",
});
expect(fetchImpl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
headers: expect.objectContaining({
authorization: "Bearer ghp_stored_token",
}),
}),
);
} finally {
globalThis.fetch = previousFetch;
if (previousAdeHome == null) delete process.env.ADE_HOME;
else process.env.ADE_HOME = previousAdeHome;
if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN;
else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken;
if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGitHubToken;
if (previousGhToken == null) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = previousGhToken;
if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR;
else process.env.GH_CONFIG_DIR = previousGhConfigDir;
}
});

it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => {
const previousAdeHome = process.env.ADE_HOME;
const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN;
const previousGitHubToken = process.env.GITHUB_TOKEN;
const previousGhToken = process.env.GH_TOKEN;
const previousGhConfigDir = process.env.GH_CONFIG_DIR;
const previousDisableGhAuthFallback = process.env.ADE_DISABLE_GH_AUTH_FALLBACK;
const previousFetch = globalThis.fetch;
process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-"));
process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-config-"));
delete process.env.ADE_GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK;
fs.writeFileSync(
path.join(process.env.GH_CONFIG_DIR, "hosts.yml"),
"github.com:\n oauth_token: gho_cli_token\n",
);
const machineCredentialStore = new EncryptedFileCredentialStore();
machineCredentialStore.setSync("github.token.v1", "ghp_stored_token");
machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({
accessToken: "ghu_app_user_token",
tokenType: "bearer",
Expand All @@ -687,12 +775,15 @@ describe("headlessLinearServices", () => {
}));
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const authorization = new Headers(init?.headers).get("authorization");
if (authorization !== "Bearer ghu_app_user_token") {
if (authorization !== "Bearer gho_cli_token") {
return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 });
}
return new Response(JSON.stringify({ login: "octocat" }), {
status: 200,
headers: { "content-type": "application/json" },
headers: {
"content-type": "application/json",
"x-oauth-scopes": "repo, workflow",
},
});
}) as unknown as typeof fetch;
globalThis.fetch = fetchImpl;
Expand All @@ -703,14 +794,87 @@ describe("headlessLinearServices", () => {
);
try {
await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({
authSource: "app",
authSource: "gh",
connected: true,
patTokenStored: true,
userLogin: "octocat",
});
expect(fetchImpl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
headers: expect.objectContaining({
authorization: "Bearer gho_cli_token",
}),
}),
);
} finally {
globalThis.fetch = previousFetch;
if (previousAdeHome == null) delete process.env.ADE_HOME;
else process.env.ADE_HOME = previousAdeHome;
if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN;
else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken;
if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGitHubToken;
if (previousGhToken == null) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = previousGhToken;
if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR;
else process.env.GH_CONFIG_DIR = previousGhConfigDir;
if (previousDisableGhAuthFallback == null) delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK;
else process.env.ADE_DISABLE_GH_AUTH_FALLBACK = previousDisableGhAuthFallback;
}
});

it("preserves failed GitHub CLI diagnostics when a stored PAT is the fallback", async () => {
const previousAdeHome = process.env.ADE_HOME;
const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN;
const previousGitHubToken = process.env.GITHUB_TOKEN;
const previousGhToken = process.env.GH_TOKEN;
const previousFetch = globalThis.fetch;
process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-pat-fallback-"));
delete process.env.ADE_GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
const machineCredentialStore = new EncryptedFileCredentialStore();
machineCredentialStore.setSync("github.token.v1", "ghp_stored_token");
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ login: "octocat" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-oauth-scopes": "repo, workflow",
},
})) as unknown as typeof fetch;

const githubService = createHeadlessGitHubService(
"/tmp/ade-project",
{ debug() {}, info() {}, warn() {}, error() {} } as any,
{
ghAuthTokenProvider: () => ({
token: null,
ghCliPath: "/usr/local/bin/gh",
ghAuthError: "not logged in to github.com",
}),
},
);
try {
expect(githubService.getTokenOrThrow()).toBe("ghp_stored_token");
await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({
authSource: "pat",
connected: true,
patTokenStored: true,
ghCliPath: "/usr/local/bin/gh",
ghAuthError: "not logged in to github.com",
userLogin: "octocat",
});
} finally {
globalThis.fetch = previousFetch;
if (previousAdeHome == null) delete process.env.ADE_HOME;
else process.env.ADE_HOME = previousAdeHome;
if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN;
else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken;
if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGitHubToken;
if (previousGhToken == null) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = previousGhToken;
}
});

Expand Down
Loading