clearclaw 0.6.1 → 0.8.0

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.
@@ -0,0 +1,6 @@
1
+ import type { EngineEvent, PlanUsageState } from "./types.js";
2
+ /** Latest normalized readings shared by workspaces on the same engine/account. */
3
+ export declare function recordPlanUsage(state: PlanUsageState, event: Extract<EngineEvent, {
4
+ type: "plan_usage";
5
+ }>): void;
6
+ export declare function formatPlanUsage(state?: PlanUsageState, maxLength?: number): string;
@@ -0,0 +1,31 @@
1
+ /** Latest normalized readings shared by workspaces on the same engine/account. */
2
+ export function recordPlanUsage(state, event) {
3
+ for (const window of event.windows)
4
+ state.windows.set(window.id, window);
5
+ if (event.isUsingOverage !== undefined)
6
+ state.isUsingOverage = event.isUsingOverage;
7
+ }
8
+ export function formatPlanUsage(state, maxLength = 4096) {
9
+ // A window with nothing to report is omitted: "5h ?" tells the reader less than
10
+ // its absence does, and the seeded windows start out unreported.
11
+ const parts = [];
12
+ for (const window of state?.windows.values() ?? []) {
13
+ if (window.usedPercent !== undefined)
14
+ parts.push(`${window.label} ${Math.round(window.usedPercent)}%`);
15
+ else if (window.limited)
16
+ parts.push(`${window.label} limited`);
17
+ }
18
+ if (state?.isUsingOverage)
19
+ parts.push("overage");
20
+ const kept = [];
21
+ for (const part of parts) {
22
+ const remaining = parts.length - kept.length - 1;
23
+ const candidate = [...kept, part].join(", ") + (remaining ? ` +${remaining} more` : "");
24
+ if (candidate.length > maxLength)
25
+ break;
26
+ kept.push(part);
27
+ }
28
+ const omitted = parts.length - kept.length;
29
+ return kept.join(", ") + (omitted ? `${kept.length ? " " : ""}+${omitted} more` : "");
30
+ }
31
+ //# sourceMappingURL=plan-usage.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Assemble the system prompt from framework and user instruction files.
3
+ * Framework content comes first, user content appended after.
4
+ * `prompt` is undefined if no content was found in either directory;
5
+ * `skipped` names files that existed but could not be read.
6
+ */
7
+ export declare function assemblePrompt(frameworkDir: string, instructionsDir: string): Promise<{
8
+ prompt: string | undefined;
9
+ skipped: string[];
10
+ }>;
package/dist/prompt.js ADDED
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import log from "./logger.js";
5
+ // A cloud-synced instructions dir (OneDrive/iCloud "dataless" placeholder) can
6
+ // block a read indefinitely. Reading it synchronously froze the whole relay.
7
+ const READ_TIMEOUT_MS = 5000;
8
+ /** Read a file, giving up after READ_TIMEOUT_MS. Returns null on timeout or error. */
9
+ async function readFileOrSkip(file) {
10
+ // ponytail: Promise.race, not AbortSignal — an abort can't interrupt a read
11
+ // already blocked in libuv's threadpool, which is exactly what a
12
+ // cloud-placeholder file does.
13
+ let timer;
14
+ const timeout = new Promise((resolve) => {
15
+ timer = setTimeout(() => resolve(null), READ_TIMEOUT_MS);
16
+ });
17
+ try {
18
+ const content = await Promise.race([fsp.readFile(file, "utf-8"), timeout]);
19
+ if (content === null) {
20
+ log.warn("[prompt] timed out reading %s after %dms — skipping", file, READ_TIMEOUT_MS);
21
+ return null;
22
+ }
23
+ return content.trim();
24
+ }
25
+ catch (err) {
26
+ log.warn({ err }, "[prompt] could not read %s — skipping", file);
27
+ return null;
28
+ }
29
+ finally {
30
+ clearTimeout(timer);
31
+ }
32
+ }
33
+ /**
34
+ * Read all .md files from a directory, sorted alphabetically.
35
+ * Unreadable files are reported by name instead of silently dropped.
36
+ */
37
+ async function readMarkdownFiles(dir) {
38
+ if (!fs.existsSync(dir))
39
+ return { contents: [], skipped: [] };
40
+ let entries;
41
+ try {
42
+ entries = await fsp.readdir(dir);
43
+ }
44
+ catch (err) {
45
+ // Cloud-synced dirs (Google Drive File Provider) can transiently refuse a listing with EPERM.
46
+ log.warn({ err }, "[prompt] could not list %s — skipping", dir);
47
+ return { contents: [], skipped: [dir] };
48
+ }
49
+ const names = entries.filter((f) => f.endsWith(".md")).sort();
50
+ const results = await Promise.all(names.map(async (f) => ({ name: f, text: await readFileOrSkip(path.join(dir, f)) })));
51
+ return {
52
+ contents: results.map((r) => r.text).filter((text) => Boolean(text)),
53
+ skipped: results.filter((r) => r.text === null).map((r) => r.name),
54
+ };
55
+ }
56
+ /**
57
+ * Assemble the system prompt from framework and user instruction files.
58
+ * Framework content comes first, user content appended after.
59
+ * `prompt` is undefined if no content was found in either directory;
60
+ * `skipped` names files that existed but could not be read.
61
+ */
62
+ export async function assemblePrompt(frameworkDir, instructionsDir) {
63
+ const framework = await readMarkdownFiles(frameworkDir);
64
+ const user = await readMarkdownFiles(instructionsDir);
65
+ const skipped = [...framework.skipped, ...user.skipped];
66
+ if (framework.contents.length === 0 && user.contents.length === 0) {
67
+ return { prompt: undefined, skipped };
68
+ }
69
+ const parts = [];
70
+ if (framework.contents.length > 0) {
71
+ parts.push(framework.contents.join("\n\n---\n\n"));
72
+ }
73
+ if (user.contents.length > 0) {
74
+ parts.push(user.contents.join("\n\n---\n\n"));
75
+ }
76
+ return { prompt: parts.join("\n\n"), skipped };
77
+ }
78
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1,15 @@
1
+ import type { Config, ScheduleEntry } from "./config.js";
2
+ import type { InboundMessage } from "./types.js";
3
+ export declare class Scheduler {
4
+ private config;
5
+ private inject;
6
+ private jobs;
7
+ constructor(config: Config, inject: (msg: Pick<InboundMessage, "origin" | "text">) => void);
8
+ start(): void;
9
+ stop(): void;
10
+ add(entry: ScheduleEntry): void;
11
+ remove(id: string): void;
12
+ toggle(id: string, enabled: boolean): void;
13
+ list(): ScheduleEntry[];
14
+ private createJob;
15
+ }
@@ -0,0 +1,82 @@
1
+ import { Cron } from "croner";
2
+ import log from "./logger.js";
3
+ export class Scheduler {
4
+ config;
5
+ inject;
6
+ jobs = new Map();
7
+ constructor(config, inject) {
8
+ this.config = config;
9
+ this.inject = inject;
10
+ }
11
+ start() {
12
+ const schedules = this.config.listSchedules();
13
+ let started = 0;
14
+ for (const entry of schedules) {
15
+ if (entry.enabled) {
16
+ this.createJob(entry);
17
+ started++;
18
+ }
19
+ }
20
+ if (started > 0) {
21
+ log.info("[scheduler] started %d schedule(s)", started);
22
+ }
23
+ }
24
+ stop() {
25
+ for (const [id, job] of this.jobs) {
26
+ job.stop();
27
+ log.info("[scheduler] stopped job %s", id);
28
+ }
29
+ this.jobs.clear();
30
+ }
31
+ add(entry) {
32
+ this.config.addSchedule(entry);
33
+ if (entry.enabled) {
34
+ this.createJob(entry);
35
+ }
36
+ log.info("[scheduler] added %s: %s → %s", entry.id, entry.cron, entry.prompt.slice(0, 60));
37
+ }
38
+ remove(id) {
39
+ const job = this.jobs.get(id);
40
+ if (job) {
41
+ job.stop();
42
+ this.jobs.delete(id);
43
+ }
44
+ this.config.removeSchedule(id);
45
+ log.info("[scheduler] removed %s", id);
46
+ }
47
+ toggle(id, enabled) {
48
+ this.config.updateSchedule(id, { enabled });
49
+ if (enabled) {
50
+ const entry = this.config.listSchedules().find((s) => s.id === id);
51
+ if (entry && !this.jobs.has(id)) {
52
+ this.createJob(entry);
53
+ }
54
+ }
55
+ else {
56
+ const job = this.jobs.get(id);
57
+ if (job) {
58
+ job.stop();
59
+ this.jobs.delete(id);
60
+ }
61
+ }
62
+ log.info("[scheduler] %s → %s", id, enabled ? "enabled" : "disabled");
63
+ }
64
+ list() {
65
+ return this.config.listSchedules();
66
+ }
67
+ createJob(entry) {
68
+ const opts = {};
69
+ if (entry.timezone)
70
+ opts.timezone = entry.timezone;
71
+ const job = new Cron(entry.cron, opts, () => {
72
+ log.info("[scheduler] firing %s: %s", entry.id, entry.prompt.slice(0, 60));
73
+ this.inject({ origin: { kind: "scheduler", scheduleId: entry.id }, text: entry.prompt });
74
+ if (!job.nextRun()) {
75
+ this.remove(entry.id);
76
+ log.info("[scheduler] one-shot %s auto-deleted", entry.id);
77
+ }
78
+ });
79
+ this.jobs.set(entry.id, job);
80
+ }
81
+ }
82
+ //# sourceMappingURL=scheduler.js.map
@@ -13,7 +13,7 @@ export interface ToolPromptResult {
13
13
  buttons: Button[][];
14
14
  mapResponse: (resp: ButtonResponse) => PermissionResponse;
15
15
  }
16
- export type ToolPromptHandler = (input: Record<string, unknown>, description: string) => ToolPromptResult | null;
16
+ export type ToolPromptHandler = (input: Record<string, unknown>) => ToolPromptResult | null;
17
17
  export declare const permissionHandlers: Map<string, ToolPromptHandler>;
18
18
  /** Tools that suppress the default rolling status line in routeEngineEvent. */
19
19
  export declare const displayHandledTools: Set<string>;
@@ -34,8 +34,8 @@ function handleAskUserQuestion(input) {
34
34
  },
35
35
  };
36
36
  }
37
- function handleExitPlanMode(input, description) {
38
- const text = formatExitPlanMode(input, description);
37
+ function handleExitPlanMode(input) {
38
+ const text = formatExitPlanMode(input);
39
39
  return {
40
40
  text,
41
41
  buttons: [
package/dist/types.d.ts CHANGED
@@ -4,12 +4,16 @@ export interface ChannelEvents {
4
4
  }
5
5
  export interface Channel {
6
6
  name: string;
7
+ /** Character budget for the chat status surface, e.g. Slack channel topic. */
8
+ statusMaxLength?: number;
7
9
  connect(): Promise<void>;
8
10
  disconnect(): Promise<void>;
9
11
  ownsId(chatId: string): boolean;
10
- sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
12
+ /** This user's own top-level DM with the bot — home's only valid chat. */
13
+ isRootDM(chatId: string, userId: string): boolean;
14
+ sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
11
15
  sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
12
- editMessage(chatId: string, handle: string, text: string): Promise<void>;
16
+ editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
13
17
  deleteMessage(chatId: string, handle: string): Promise<void>;
14
18
  pinMessage(chatId: string, handle: string): Promise<void>;
15
19
  unpinAllMessages(chatId: string): Promise<void>;
@@ -17,6 +21,15 @@ export interface Channel {
17
21
  setTyping(chatId: string, isTyping: boolean): Promise<void>;
18
22
  sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
19
23
  reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
24
+ /**
25
+ * Group an existing chat under its Project, creating the grouping if it does not exist yet.
26
+ * Returns a note when grouping did not happen, so the caller can tell the user.
27
+ */
28
+ groupProjectChat?(projectName: string, chatId: string): Promise<string | undefined>;
29
+ /** Create a peer chat. Returns an opaque chat ID; grouping is a separate step. */
30
+ createProjectChat?(projectName: string, anchor: string, title: string): Promise<string>;
31
+ /** Close a workspace's chat and remove its Project grouping, regardless of origin. */
32
+ closeProjectChat?(chatId: string, projectName?: string): Promise<void>;
20
33
  on<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
21
34
  off<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
22
35
  emit<K extends keyof ChannelEvents>(event: K, ...args: ChannelEvents[K]): boolean;
@@ -30,11 +43,14 @@ export interface ButtonResponse {
30
43
  value: string;
31
44
  text?: string;
32
45
  }
33
- export interface SendMessageOpts {
46
+ export type TextFormat = "markdown" | "plain";
47
+ export interface MessageOpts {
34
48
  /** When false, skip consuming the typing placeholder (e.g. for tool status messages). */
35
49
  consumeTyping?: boolean;
36
50
  /** Reply/thread to a specific platform message ID (Telegram reply, Slack thread). */
37
51
  replyToMessageId?: string;
52
+ /** Text format hint. Defaults to "markdown". */
53
+ format?: TextFormat;
38
54
  }
39
55
  export interface SendFileOpts {
40
56
  caption?: string;
@@ -46,10 +62,24 @@ export interface SessionInfo {
46
62
  lastModified: number;
47
63
  gitBranch?: string;
48
64
  }
65
+ /** Chronological conversation text, excluding tool payloads and internal reasoning. */
66
+ export interface SessionMessage {
67
+ role: "user" | "assistant";
68
+ text: string;
69
+ }
70
+ export interface SessionHistoryOpts {
71
+ sessionId: string;
72
+ cwd: string;
73
+ signal?: AbortSignal;
74
+ }
49
75
  export interface Engine {
50
76
  name: string;
77
+ /** Known account windows to show as unknown before the first observation. */
78
+ planUsageWindows?: readonly PlanUsageWindow[];
51
79
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
52
80
  listSessions(cwd: string): Promise<SessionInfo[]>;
81
+ /** Read history without submitting a new prompt. Reject on unsupported/failed retrieval. */
82
+ getSessionMessages(opts: SessionHistoryOpts): Promise<SessionMessage[]>;
53
83
  }
54
84
  export interface RunTurnOpts {
55
85
  sessionId: string | null;
@@ -57,45 +87,98 @@ export interface RunTurnOpts {
57
87
  prompt: string;
58
88
  attachments?: Attachment[];
59
89
  permissionMode: PermissionMode;
60
- onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResponse>;
90
+ onPermissionRequest: (req: ToolCall) => Promise<PermissionResponse>;
61
91
  appendSystemPrompt?: string;
62
92
  mcpServers?: Record<string, McpServerConfig>;
63
93
  signal?: AbortSignal;
94
+ model?: string;
64
95
  }
65
- export interface PermissionRequest {
96
+ interface ToolCallBase {
66
97
  toolName: string;
67
- input: Record<string, unknown>;
68
- description: string;
69
- reason?: string;
70
98
  toolUseId: string;
71
99
  }
100
+ declare const ACTIONS: {
101
+ readonly edit: "edit";
102
+ readonly write: "write";
103
+ readonly execute: "execute";
104
+ readonly read: "read";
105
+ readonly search: "search";
106
+ readonly fetch: "fetch";
107
+ };
108
+ export type KnownToolCall = (ToolCallBase & {
109
+ action: typeof ACTIONS.edit;
110
+ path: string;
111
+ before: string;
112
+ after: string;
113
+ }) | (ToolCallBase & {
114
+ action: typeof ACTIONS.write;
115
+ path: string;
116
+ content: string;
117
+ }) | (ToolCallBase & {
118
+ action: typeof ACTIONS.execute;
119
+ command: string;
120
+ }) | (ToolCallBase & {
121
+ action: typeof ACTIONS.read;
122
+ paths: string[];
123
+ }) | (ToolCallBase & {
124
+ action: typeof ACTIONS.search;
125
+ pattern: string;
126
+ paths?: string[];
127
+ }) | (ToolCallBase & {
128
+ action: typeof ACTIONS.fetch;
129
+ url: string;
130
+ });
131
+ export interface UnknownToolCall extends ToolCallBase {
132
+ action: string;
133
+ [key: string]: unknown;
134
+ }
135
+ export type ToolCall = KnownToolCall | UnknownToolCall;
136
+ export declare function isKnownToolCall(tool: ToolCall): tool is KnownToolCall;
72
137
  export interface PermissionResponse {
73
138
  decision: "allow" | "deny";
74
139
  message?: string;
75
140
  updatedInput?: Record<string, unknown>;
76
141
  }
77
142
  export interface TurnStats {
78
- model: string;
143
+ modelLabel?: string;
144
+ model: string | null;
79
145
  contextUsed: number;
80
146
  contextWindow: number;
81
147
  toolCalls: Record<string, number>;
82
148
  }
149
+ /** Account quota, distinct from a session's context/token usage. Reset times are Unix seconds. */
150
+ export interface PlanUsageWindow {
151
+ id: string;
152
+ label: string;
153
+ usedPercent?: number;
154
+ resetsAt?: number;
155
+ limited?: boolean;
156
+ }
157
+ export interface PlanUsageState {
158
+ windows: Map<string, PlanUsageWindow>;
159
+ isUsingOverage?: boolean;
160
+ }
83
161
  export type EngineEvent = {
84
162
  type: "text";
85
163
  text: string;
164
+ } | {
165
+ type: "text_chunk";
166
+ text: string;
86
167
  } | {
87
168
  type: "tool_use";
88
- toolName: string;
89
- input: Record<string, unknown>;
90
- toolUseId: string;
169
+ tool: ToolCall;
91
170
  } | {
92
171
  type: "tool_result";
93
172
  toolName: string;
94
173
  output: string;
95
174
  } | {
96
- type: "rate_limit";
97
- status: string;
98
- resetsAt?: number;
175
+ type: "plan_usage";
176
+ windows: PlanUsageWindow[];
177
+ isUsingOverage?: boolean;
178
+ } | {
179
+ type: "session";
180
+ sessionId: string;
181
+ model?: string;
99
182
  } | {
100
183
  type: "done";
101
184
  sessionId: string;
@@ -104,12 +187,27 @@ export type EngineEvent = {
104
187
  type: "error";
105
188
  message: string;
106
189
  };
190
+ export interface EngineHandoff {
191
+ engine: string;
192
+ sessionId: string;
193
+ cwd: string;
194
+ }
107
195
  export interface Workspace {
108
196
  name: string;
109
197
  cwd: string;
110
- chat_id: string;
198
+ chat_id: string | null;
111
199
  current_session_id: string | null;
200
+ engine_handoff?: EngineHandoff;
112
201
  behavior?: "assistant" | "relay";
202
+ engine?: string;
203
+ model?: string;
204
+ project?: string;
205
+ description?: string;
206
+ spawnedFrom?: string;
207
+ pending_brief?: {
208
+ fromWorkspace: string;
209
+ text: string;
210
+ };
113
211
  }
114
212
  export interface UserInfo {
115
213
  id: string;
@@ -121,6 +219,8 @@ export interface Attachment {
121
219
  buffer: Buffer;
122
220
  mimeType: string;
123
221
  filename?: string;
222
+ /** Absolute path where the orchestrator saved this file to disk. */
223
+ savedAs?: string;
124
224
  }
125
225
  /** Context from a replied-to / quoted message. */
126
226
  export interface ReplyContext {
@@ -130,10 +230,21 @@ export interface ReplyContext {
130
230
  mediaType?: string;
131
231
  }
132
232
  export type ChatType = "dm" | "group";
233
+ /** Where an inbound message came from. */
234
+ export type MessageOrigin = {
235
+ kind: "user";
236
+ user: UserInfo;
237
+ } | {
238
+ kind: "scheduler";
239
+ scheduleId: string;
240
+ } | {
241
+ kind: "peer";
242
+ workspaceName: string;
243
+ };
133
244
  export interface InboundMessage {
134
245
  chatId: string;
135
246
  chatType: ChatType;
136
- user: UserInfo;
247
+ origin: MessageOrigin;
137
248
  text: string;
138
249
  messageId?: string;
139
250
  replyTo?: ReplyContext;
package/dist/types.js CHANGED
@@ -1,2 +1,13 @@
1
- export {};
1
+ const ACTIONS = {
2
+ edit: "edit",
3
+ write: "write",
4
+ execute: "execute",
5
+ read: "read",
6
+ search: "search",
7
+ fetch: "fetch",
8
+ };
9
+ const knownActions = new Set(Object.values(ACTIONS));
10
+ export function isKnownToolCall(tool) {
11
+ return knownActions.has(tool.action);
12
+ }
2
13
  //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clearclaw",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Your coding agent, from your phone — personal assistant + project workspaces on your machine, built on the CLI's permissions, config, and memory instead of replacing them.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,25 +26,33 @@
26
26
  },
27
27
  "files": [
28
28
  "dist/**/*.js",
29
- "dist/**/*.d.ts"
29
+ "dist/**/*.d.ts",
30
+ "prompts/**/*.md",
31
+ "scripts/patch-codex-acp.mjs",
32
+ "patches/**"
30
33
  ],
31
34
  "scripts": {
32
35
  "start": "tsx src/index.ts daemon",
33
36
  "dev": "tsx --watch src/index.ts daemon",
34
37
  "dev:relay": "nodemon --watch dist --exec 'node dist/index.js daemon'",
35
38
  "build": "tsc",
36
- "check": "tsc --noEmit"
39
+ "check": "tsc --noEmit",
40
+ "test": "node --import tsx --test test/*.test.ts",
41
+ "postinstall": "node scripts/patch-codex-acp.mjs"
37
42
  },
38
43
  "dependencies": {
44
+ "@agentclientprotocol/codex-acp": "1.11.0",
45
+ "@agentclientprotocol/sdk": "^0.16.1",
39
46
  "@anthropic-ai/claude-agent-sdk": "^0.2.89",
47
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
48
  "@slack/bolt": "^4.6.0",
49
+ "croner": "^10.0.1",
41
50
  "diff": "^8.0.3",
42
51
  "grammy": "^1.30.0",
43
52
  "mime": "^4.1.0",
44
53
  "pino": "^10.3.1",
45
54
  "pino-pretty": "^13.1.3",
46
55
  "pino-roll": "^4.0.0",
47
- "slackify-markdown": "^5.0.0",
48
56
  "telegramify-markdown": "^1.3.3"
49
57
  },
50
58
  "devDependencies": {
@@ -52,6 +60,7 @@
52
60
  "@types/diff": "^7.0.2",
53
61
  "@types/node": "^22.0.0",
54
62
  "nodemon": "^3.1.14",
63
+ "telegram": "^2.26.22",
55
64
  "tsx": "^4.19.0",
56
65
  "typescript": "^5.7.0"
57
66
  }
@@ -0,0 +1,13 @@
1
+ # Codex ACP usage metadata backport
2
+
3
+ ClearClaw temporarily pins `@agentclientprotocol/codex-acp` to npm version `1.11.0` and backports the usage notification changes from [codex-acp PR #334](https://github.com/agentclientprotocol/codex-acp/pull/334), head [`b670a4e4914fbd5ffe2d008091e64921dc2684d4`](https://github.com/agentclientprotocol/codex-acp/commit/b670a4e4914fbd5ffe2d008091e64921dc2684d4). The upstream adapter and these adapted changes are Apache-2.0 licensed.
4
+
5
+ The npm release distributes a bundled `dist/index.js`, so the patch targets that bundle. It forwards account limit updates as ACP `usage_update` notifications and includes all cached quota buckets in `_meta["_codex/rateLimits"]` on context usage updates. Notifications retain the upstream requirement that context usage and a positive context window are known. This does not fetch quotas on demand.
6
+
7
+ Only the emission changes are backported. Version 1.11.0's existing `mergeRateLimitSnapshot` logic remains intact, including preservation of account-level fields on sparse updates.
8
+
9
+ `scripts/patch-codex-acp.mjs` runs after installation and uses ClearClaw's existing `diff` dependency. It resolves the installed adapter, checks its exact version and original SHA-256 hash, applies this patch, and verifies the resulting hash. An already patched bundle is accepted, making repeat installation idempotent. Unexpected source fails installation instead of silently dropping quota data.
10
+
11
+ The installer modifies the resolved dependency in place. In an installation where npm hoists and shares that exact adapter version, other consumers also receive these additive usage metadata notifications. Both the script and patch are included in ClearClaw's published package. Installations using `--ignore-scripts` must explicitly run `node scripts/patch-codex-acp.mjs` from the ClearClaw package directory before relying on Codex quota reporting.
12
+
13
+ Remove the installer and patch when an upstream release includes this metadata contract, then update the pinned dependency and verify usage notifications.
@@ -0,0 +1,38 @@
1
+ --- a/dist/index.js
2
+ +++ b/dist/index.js
3
+ @@ -24471,7 +24471,7 @@
4
+ return this.createMcpToolProgressEvent(notification.params);
5
+ case "account/rateLimits/updated":
6
+ this.handleRateLimitsUpdated(notification.params);
7
+ - return null;
8
+ + return this.createUsageUpdateFromState();
9
+ case "account/updated":
10
+ this.onAccountUpdated?.(notification.params);
11
+ return null;
12
+ @@ -25155,6 +25155,9 @@
13
+ }
14
+ createUsageUpdate(params) {
15
+ this.handleTokenUsageUpdated(params);
16
+ + return this.createUsageUpdateFromState();
17
+ + }
18
+ + createUsageUpdateFromState() {
19
+ const used = this.sessionState.lastTokenUsage?.totalTokens;
20
+ const size = this.sessionState.modelContextWindow;
21
+ if (used == null || size == null || size <= 0) {
22
+ @@ -25163,7 +25166,15 @@
23
+ return {
24
+ sessionUpdate: "usage_update",
25
+ used,
26
+ - size
27
+ + size,
28
+ + ...this.sessionState.rateLimits ? {
29
+ + _meta: {
30
+ + "_codex/rateLimits": Array.from(this.sessionState.rateLimits.values(), (entry) => ({
31
+ + ...entry.snapshot,
32
+ + limitId: entry.limitId
33
+ + }))
34
+ + }
35
+ + } : {}
36
+ };
37
+ }
38
+ handleRateLimitsUpdated(params) {