Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Pre-release packages are published on matching npm dist-tags instead of `latest`

- Uses your existing local Codex CLI setup instead of a separate hosted bridge.
- Feels natural in chat: bind once with `/cas_resume`, then just talk.
- Keeps useful controls close at hand with `/cas_status`, `/cas_plan`, `/cas_review`, `/cas_model`, and more.
- Keeps useful controls close at hand with `/cas_status`, `/cas_monitor`, `/cas_plan`, `/cas_review`, `/cas_model`, and more.
- Works well for Telegram and Discord conversations that you want tied to a real Codex thread.

## Typical Workflow
Expand All @@ -62,6 +62,9 @@ Pre-release packages are published on matching npm dist-tags instead of `latest`
| `/cas_resume --sync` | Resume and try to sync the chat/topic name to the Codex thread. | You can combine this with other flags. |
| `/cas_resume release-fix` | Resume a matching thread by title or id. | If more than one thread matches, you get buttons to choose. |
| `/cas_status` | Show the current binding and thread state. | Includes thread id, model, workspace, sandbox, and permissions when available. |
| `/cas_monitor` | Bind this conversation as a cross-thread monitor surface. | Shows pending approvals, pending questions, and unread thread activity across sessions. |
| `/cas_monitor --cwd ~/github/openclaw` | Limit monitor mode to one workspace. | Uses the same `--cwd` path handling as `/cas_resume`. |
| `/cas_monitor status|off` | Inspect or disable monitor mode. | `off` is also compatible with `/cas_detach`. |
| `/cas_detach` | Unbind this conversation from Codex. | Stops routing plain text from this conversation into the bound thread. |
| `/cas_stop` | Interrupt the active Codex run. | Only applies when a turn is currently in progress. |
| `/cas_steer <message>` | Send follow-up steer text to an active run. | Example: `/cas_steer focus on the failing tests first` |
Expand Down
1 change: 1 addition & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("plugin registration", () => {
expect(api.registerCommand).toHaveBeenCalled();
expect(api.registerCommand.mock.calls.map(([params]) => params.name)).toEqual([
"cas_resume",
"cas_monitor",
"cas_detach",
"cas_status",
"cas_stop",
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { INTERACTIVE_NAMESPACE } from "./src/types.js";

const COMMANDS = [
["cas_resume", "Resume or bind an existing Codex thread."],
["cas_monitor", "Monitor cross-thread approvals, prompts, and unread activity."],
["cas_detach", "Detach this conversation from the current Codex thread."],
["cas_status", "Show the current Codex binding and thread state."],
["cas_stop", "Stop the active Codex turn."],
Expand Down
33 changes: 33 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,39 @@ describe("extractThreadTokenUsageSnapshot", () => {
remainingPercent: 80,
});
});

it("extracts thread status and active flags from thread list payloads", () => {
expect(
__testing.extractThreadsFromValue({
threads: [
{
id: "thread-1",
name: "Needs approval",
cwd: "/repo/openclaw",
updatedAt: 1_710_000_000,
status: {
type: "active",
activeFlags: ["waitingOnApproval", "waitingOnUserInput"],
},
},
],
}),
).toEqual([
{
threadId: "thread-1",
title: "Needs approval",
summary: "",
projectKey: "/repo/openclaw",
createdAt: undefined,
updatedAt: 1_710_000_000_000,
gitBranch: undefined,
status: {
type: "active",
activeFlags: ["waitingOnApproval", "waitingOnUserInput"],
},
},
]);
});
});

describe("extractFileChangePathsFromReadResult", () => {
Expand Down
45 changes: 45 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import type {
ReviewResult,
ReviewTarget,
SkillSummary,
ThreadActiveFlag,
ThreadReplay,
ThreadState,
ThreadStatusSummary,
ThreadSummary,
TurnTerminalError,
TurnResult,
Expand Down Expand Up @@ -1217,13 +1219,51 @@ function extractThreadsFromValue(value: unknown): ThreadSummary[] {
pickString(asRecord(record.git_info) ?? {}, ["branch"]) ??
pickString(asRecord(sessionRecord?.gitInfo) ?? {}, ["branch"]) ??
pickString(asRecord(sessionRecord?.git_info) ?? {}, ["branch"]),
status: extractThreadStatus(record) ?? extractThreadStatus(sessionRecord),
});
}
return [...summaries.values()].sort(
(left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0),
);
}

function normalizeThreadActiveFlag(value: unknown): ThreadActiveFlag | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return normalized === "waitingOnApproval" || normalized === "waitingOnUserInput"
? normalized
: undefined;
}

function extractThreadStatus(value: unknown): ThreadStatusSummary | undefined {
const record = asRecord(asRecord(value)?.status ?? value);
if (!record) {
const statusType = typeof value === "string" ? value.trim() : "";
if (
statusType === "notLoaded" ||
statusType === "idle" ||
statusType === "systemError"
) {
return { type: statusType };
}
return undefined;
}
const type = pickString(record, ["type", "status", "kind"]);
if (type === "notLoaded" || type === "idle" || type === "systemError") {
return { type };
}
if (type === "active") {
const rawFlags = findFirstArrayByKeys(record, ["activeFlags", "active_flags"]) ?? [];
const activeFlags = rawFlags
.map((entry) => normalizeThreadActiveFlag(entry))
.filter((entry): entry is ThreadActiveFlag => Boolean(entry));
return { type, activeFlags };
}
return undefined;
}

function normalizeConversationRole(value: string | undefined): "user" | "assistant" | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "user" || normalized === "usermessage") {
Expand Down Expand Up @@ -2529,6 +2569,10 @@ export class CodexAppServerClient {
await connection?.client.close().catch(() => undefined);
}

onNotification(listener: (method: string, params: unknown) => Promise<void> | void): () => void {
return this.addNotificationListener(listener);
}

async listThreads(params: {
sessionKey?: string;
workspaceDir?: string;
Expand Down Expand Up @@ -3618,6 +3662,7 @@ export const __testing = {
extractStartupProbeInfo,
formatFileEditNotice,
extractThreadTokenUsageSnapshot,
extractThreadsFromValue,
extractRateLimitSummaries,
formatStdioProcessLog,
resolveTurnStoppedReason,
Expand Down
Loading