abelworkflow 1.2.2 → 1.2.4

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.
package/README.md CHANGED
@@ -68,14 +68,24 @@ python -m pip install -r skills/grok-search/requirements.txt
68
68
 
69
69
  ## 配置行为
70
70
 
71
- - Claude 配置管理 `ANTHROPIC_BASE_URL`、`ANTHROPIC_API_KEY` `ANTHROPIC_MODEL`;冲突的 `ANTHROPIC_AUTH_TOKEN` 会被删除,其他模型路由保持不变。
72
- - Codex 配置管理 AbelWorkflow Provider 路由、`OPENAI_API_KEY` `auth_mode`,清理冲突的 `preferred_auth_method`、`temp_env_key` 及目标 Provider 旧路由字段,保留其他认证字段。
71
+ - Claude API 配置会合并个人默认模板(`$schema`、`attribution`、`hooks`、`alwaysThinkingEnabled`、`language` 与全局权限白名单),删除已弃用的 `includeCoAuthoredBy`;白名单使用当前任务工具 `TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`,缺失项补入默认模板但保留已有权限数组。
72
+ - Claude API 使用 `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` 统一关闭非必要流量并保留 `API_TIMEOUT_MS`,清理重叠的 `DISABLE_TELEMETRY` `DISABLE_ERROR_REPORTING`。
73
+ - Claude API 管理 `ANTHROPIC_BASE_URL`、`ANTHROPIC_API_KEY`,并将所选模型同步到 `ANTHROPIC_MODEL`、Opus/Sonnet/Haiku 默认模型与 `CLAUDE_CODE_SUBAGENT_MODEL`;冲突的 `ANTHROPIC_AUTH_TOKEN` 会被删除,其他用户配置保持不变。
74
+ - Codex 配置以 Codex 最新稳定版为基线,不保留旧版本兼容配置;配置会合并个人默认模板,使用 `approval_policy = "never"` 与 `sandbox_mode = "danger-full-access"` 赋予 Agent 完全权限,同时管理 AbelWorkflow Provider 路由、`OPENAI_API_KEY` 与 `auth_mode`,清理冲突的 `preferred_auth_method`、`temp_env_key` 及目标 Provider 旧路由字段,保留其他认证字段。
73
75
  - Pi 配置更新 `abelworkflow` Provider、对应凭据及全局 `defaultProvider`、`defaultModel`,保留其他 Provider 与无关设置。
74
- - API 配置不注入全局权限;配置结构或路径无法安全合并时直接失败。
76
+ - 所有 Provider 在配置结构或路径无法安全合并时直接失败。
75
77
  - Skill 密钥写入 `~/.agents/skills/<skill>/.env`,属于用户私密配置。
76
78
  - `.skill-lock.json` 完全属于用户:安装器不读取、不修改、不备份、不删除,也不会将其放入发布包。
77
79
  - 自定义 CA 应通过标准证书环境变量显式配置,不关闭 TLS 校验。
78
80
 
81
+ ### Codex WebSocket 中转
82
+
83
+ AbelWorkflow 为目标 Provider 设置 `wire_api = "responses"` 与 `supports_websockets = true`,使 Codex 最新稳定版优先使用 Responses API WebSocket。旧版 `[features]` 配置 `responses_websockets` 与 `responses_websockets_v2` 会被清理,不作为兼容开关保留。
84
+
85
+ Codex 根据 Provider Base URL 推导 WebSocket 地址。例如,`https://relay.example/v1` 对应 `wss://relay.example/v1/responses`;使用 HTTP Base URL 时则对应 `ws://`。中转必须在该路径支持 WebSocket Upgrade,并实现 Codex 使用的 Responses API WebSocket 协议、认证头和事件格式;只支持 HTTP/SSE Responses API 不满足此要求。
86
+
87
+ `supports_websockets = true` 是 Provider 能力声明,AbelWorkflow 不会在写入配置时探测中转能力。如果握手或协议不兼容,请修复中转并查看 Codex 日志;是否回退 HTTP 由当前 Codex 运行时决定,AbelWorkflow 只保证默认优先尝试 WebSocket,不保证 WebSocket-only。
88
+
79
89
  ## 部署映射
80
90
 
81
91
  | 托管内容 | Claude Code | Codex | Pi |
@@ -8,6 +8,20 @@ const RESPONSES_PROVIDERS = new Set(["abelworkflow", "gpt"]);
8
8
  const UNSUPPORTED_SPARK_REASONING_EFFORTS = new Set(["none", "minimal"]);
9
9
  const TRANSIENT_UPSTREAM_ERROR = /\bupstream(?:[_ -]error|\s+request\s+failed)\b/i;
10
10
  const EXPLICIT_ERROR_STATUS = /(?:^\s*|\b(?:upstream(?:[_ -]error|\s+request\s+failed)|code|error(?:[_ -]?code)?|http(?:\/\d+(?:\.\d+)*)?(?:[_ -]?status(?:[_ -]?code)?)?|status(?:[_ -]?code)?)\b\s*["']?\s*[:=(]?\s*)["']?([45]\d{2})["']?(?!\d)/i;
11
+ const SUMMARIZATION_SYSTEM_PROMPT = "You summarize coding conversations into concise continuation checkpoints. Output only the requested structured summary.";
12
+ const SUMMARIZATION_PROMPT = `Create a structured checkpoint using exactly these sections:
13
+
14
+ ## Goal
15
+ ## Constraints & Preferences
16
+ ## Progress
17
+ ### Done
18
+ ### In Progress
19
+ ### Blocked
20
+ ## Key Decisions
21
+ ## Next Steps
22
+ ## Critical Context
23
+
24
+ Preserve exact file paths, function names, decisions, errors, and unfinished work. Be concise but include everything needed to continue.`;
11
25
 
12
26
  function isRecord(value: unknown): value is JsonRecord {
13
27
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -98,9 +112,13 @@ function normalizeInputItem(item: unknown): unknown {
98
112
  if (item.type === "reasoning") return undefined;
99
113
 
100
114
  if (item.type === "message") {
101
- const content = normalizeAssistantContent(item.content);
115
+ const role = item.role === "user" ? "user" : item.role === "assistant" ? "assistant" : undefined;
116
+ if (!role) return undefined;
117
+ const content = role === "user"
118
+ ? normalizeUserContent(item.content)
119
+ : normalizeAssistantContent(item.content);
102
120
  if (Array.isArray(content) && content.length === 0) return undefined;
103
- return { role: "assistant", content };
121
+ return { role, content };
104
122
  }
105
123
 
106
124
  if (item.role === "user") {
@@ -157,32 +175,153 @@ function normalizeReasoning(payload: JsonRecord, modelId: string | undefined): v
157
175
  payload.reasoning = { ...payload.reasoning, effort: "low" };
158
176
  }
159
177
 
178
+ function serializeConversation(messages: unknown[]): string {
179
+ return messages.map((message) => {
180
+ if (!isRecord(message)) return "";
181
+ const content = textFromContent(message.content) ?? "";
182
+ if (message.role === "user") return content ? `[User]: ${content}` : "";
183
+ if (message.role === "toolResult") {
184
+ const text = content.length > 2000 ? `${content.slice(0, 2000)}\n[truncated]` : content;
185
+ return text ? `[Tool result]: ${text}` : "";
186
+ }
187
+ if (message.role === "assistant" && Array.isArray(message.content)) {
188
+ const parts: string[] = [];
189
+ const thinking = message.content
190
+ .filter((part) => isRecord(part) && part.type === "thinking" && typeof part.thinking === "string")
191
+ .map((part) => part.thinking)
192
+ .join("\n");
193
+ if (thinking) parts.push(`[Assistant thinking]: ${thinking}`);
194
+ if (content) parts.push(`[Assistant]: ${content}`);
195
+ const calls = message.content
196
+ .filter((part) => isRecord(part) && part.type === "toolCall")
197
+ .map((part) => `${part.name}(${JSON.stringify(part.arguments ?? {})})`)
198
+ .join("; ");
199
+ if (calls) parts.push(`[Assistant tool calls]: ${calls}`);
200
+ return parts.join("\n");
201
+ }
202
+ if (message.role === "bashExecution") {
203
+ return `[Bash]: ${String(message.command ?? "")}\n${String(message.output ?? "")}`;
204
+ }
205
+ if (typeof message.summary === "string") return `[Previous checkpoint]: ${message.summary}`;
206
+ return content;
207
+ }).filter(Boolean).join("\n\n");
208
+ }
209
+
210
+ function fileDetails(fileOps: unknown): {
211
+ readFiles: string[];
212
+ modifiedFiles: string[];
213
+ } {
214
+ if (!isRecord(fileOps)) return { readFiles: [], modifiedFiles: [] };
215
+ const modified = new Set<string>([
216
+ ...(fileOps.edited instanceof Set ? fileOps.edited : []),
217
+ ...(fileOps.written instanceof Set ? fileOps.written : []),
218
+ ]);
219
+ return {
220
+ readFiles: [...(fileOps.read instanceof Set ? fileOps.read : [])]
221
+ .filter((path): path is string => typeof path === "string" && !modified.has(path))
222
+ .sort(),
223
+ modifiedFiles: [...modified].sort(),
224
+ };
225
+ }
226
+
227
+ function formatFileDetails(details: { readFiles: string[]; modifiedFiles: string[] }): string {
228
+ const sections: string[] = [];
229
+ if (details.readFiles.length > 0) {
230
+ sections.push(`<read-files>\n${details.readFiles.join("\n")}\n</read-files>`);
231
+ }
232
+ if (details.modifiedFiles.length > 0) {
233
+ sections.push(`<modified-files>\n${details.modifiedFiles.join("\n")}\n</modified-files>`);
234
+ }
235
+ return sections.length > 0 ? `\n\n${sections.join("\n\n")}` : "";
236
+ }
237
+
238
+ function normalizeResponsesPayload(
239
+ payload: unknown,
240
+ model: unknown,
241
+ systemPrompt: string,
242
+ ): JsonRecord | undefined {
243
+ if (!isRecord(payload) || !isCompatibleResponsesModel(model)) return undefined;
244
+
245
+ const input = normalizeInput(payload.input);
246
+ const first = Array.isArray(payload.input) ? payload.input.find(isRecord) : undefined;
247
+ const firstIsPrompt = first?.role === "system" || first?.role === "developer";
248
+ const instructions =
249
+ textFromContent(payload.instructions) ||
250
+ (firstIsPrompt ? textFromContent(first.content) : undefined) ||
251
+ systemPrompt.trim() ||
252
+ DEFAULT_INSTRUCTIONS;
253
+ const nextPayload: JsonRecord = { ...payload, instructions, input, store: false };
254
+
255
+ delete nextPayload.max_output_tokens;
256
+ delete nextPayload.prompt_cache_key;
257
+ delete nextPayload.prompt_cache_retention;
258
+ delete nextPayload.previous_response_id;
259
+
260
+ normalizeReasoning(nextPayload, isRecord(model) && typeof model.id === "string" ? model.id : undefined);
261
+ return nextPayload;
262
+ }
263
+
160
264
  export default function (pi: ExtensionAPI) {
161
- pi.on("before_provider_request", (event, ctx) => {
162
- const payload = event.payload as unknown;
163
- if (!isRecord(payload)) return;
164
- if (!isCompatibleResponsesModel(ctx.model)) return;
165
-
166
- const input = normalizeInput(payload.input);
167
- const first = Array.isArray(payload.input) ? payload.input.find(isRecord) : undefined;
168
- const firstIsPrompt = first?.role === "system" || first?.role === "developer";
169
- const instructions =
170
- textFromContent(payload.instructions) ||
171
- (firstIsPrompt ? textFromContent(first.content) : undefined) ||
172
- ctx.getSystemPrompt().trim() ||
173
- DEFAULT_INSTRUCTIONS;
174
-
175
- const nextPayload: JsonRecord = { ...payload, instructions, input, store: false };
176
-
177
- // Unsupported or risky for this Codex passthrough endpoint.
178
- delete nextPayload.max_output_tokens;
179
- delete nextPayload.prompt_cache_key;
180
- delete nextPayload.prompt_cache_retention;
181
- delete nextPayload.previous_response_id;
182
-
183
- normalizeReasoning(nextPayload, ctx.model?.id);
184
-
185
- return nextPayload;
265
+ pi.on("before_provider_request", (event, ctx) =>
266
+ normalizeResponsesPayload(event.payload, ctx.model, ctx.getSystemPrompt()));
267
+
268
+ pi.on("session_before_compact", async (event, ctx) => {
269
+ const model = ctx.model;
270
+ if (!isCompatibleResponsesModel(model)) return;
271
+
272
+ const { preparation } = event;
273
+ const conversation = serializeConversation([
274
+ ...preparation.messagesToSummarize,
275
+ ...preparation.turnPrefixMessages,
276
+ ]);
277
+ const prompt = [
278
+ `<conversation>\n${conversation}\n</conversation>`,
279
+ preparation.previousSummary
280
+ ? `<previous-summary>\n${preparation.previousSummary}\n</previous-summary>\nPreserve and update this previous checkpoint.`
281
+ : "",
282
+ preparation.isSplitTurn
283
+ ? "The conversation ends with the prefix of a split turn; its recent suffix remains in context."
284
+ : "",
285
+ SUMMARIZATION_PROMPT,
286
+ event.customInstructions ? `Additional focus: ${event.customInstructions}` : "",
287
+ ].filter(Boolean).join("\n\n");
288
+ const response = await ctx.modelRegistry.complete(
289
+ model,
290
+ {
291
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
292
+ messages: [{
293
+ role: "user",
294
+ content: [{ type: "text", text: prompt }],
295
+ timestamp: Date.now(),
296
+ }],
297
+ },
298
+ {
299
+ signal: event.signal,
300
+ cacheRetention: "none",
301
+ ...(ctx.thinkingLevel && ctx.thinkingLevel !== "off"
302
+ ? { reasoningEffort: ctx.thinkingLevel }
303
+ : {}),
304
+ onPayload: (payload: unknown, payloadModel: unknown) =>
305
+ normalizeResponsesPayload(payload, payloadModel, SUMMARIZATION_SYSTEM_PROMPT),
306
+ },
307
+ );
308
+ if (response.stopReason === "error") {
309
+ throw new Error(`Summarization failed: ${response.errorMessage ?? "Unknown error"}`);
310
+ }
311
+ if (response.stopReason === "aborted") throw new Error("Compaction cancelled");
312
+
313
+ const summaryText = textFromContent(response.content);
314
+ if (!summaryText) throw new Error("Summarization failed: Empty response");
315
+ const details = fileDetails(preparation.fileOps);
316
+ return {
317
+ compaction: {
318
+ summary: `${summaryText}${formatFileDetails(details)}`,
319
+ firstKeptEntryId: preparation.firstKeptEntryId,
320
+ tokensBefore: preparation.tokensBefore,
321
+ usage: response.usage,
322
+ details,
323
+ },
324
+ };
186
325
  });
187
326
 
188
327
  pi.on("message_end", (event, ctx) => {
@@ -237,10 +237,206 @@ function findAssignment(assignments, field, section) {
237
237
  return assignments.find((entry) => entry.section === section && entry.path.length === 1 && entry.path[0] === field);
238
238
  }
239
239
 
240
+ function assignmentPath(entry) {
241
+ return entry.section ? [...entry.section.path, ...entry.path] : entry.path;
242
+ }
243
+
244
+ function pathStartsWith(path, prefix) {
245
+ return prefix.length <= path.length && prefix.every((part, index) => path[index] === part);
246
+ }
247
+
248
+ function skipTomlTrivia(text, index) {
249
+ while (index < text.length) {
250
+ if (/\s/u.test(text[index])) {
251
+ index += 1;
252
+ } else if (text[index] === "#") {
253
+ const newline = text.indexOf("\n", index + 1);
254
+ index = newline === -1 ? text.length : newline + 1;
255
+ } else {
256
+ break;
257
+ }
258
+ }
259
+ return index;
260
+ }
261
+
262
+ function scanInlineTomlValue(text, index) {
263
+ const state = { arrayDepth: 0, inlineDepth: 0, mode: "normal" };
264
+ for (; index < text.length;) {
265
+ if (state.mode === "multiline-basic") {
266
+ if (text[index] === "\\") index += 2;
267
+ else if (text.startsWith('"""', index)) index = closeMultilineString(text, state, index, '"');
268
+ else index += 1;
269
+ continue;
270
+ }
271
+ if (state.mode === "multiline-literal") {
272
+ if (text.startsWith("'''", index)) index = closeMultilineString(text, state, index, "'");
273
+ else index += 1;
274
+ continue;
275
+ }
276
+ if (state.mode === "basic") {
277
+ if (text[index] === "\\") index += 2;
278
+ else if (text[index] === '"') {
279
+ state.mode = "normal";
280
+ index += 1;
281
+ } else index += 1;
282
+ continue;
283
+ }
284
+ if (state.mode === "literal") {
285
+ if (text[index] === "'") state.mode = "normal";
286
+ index += 1;
287
+ continue;
288
+ }
289
+
290
+ if (text[index] === "#") {
291
+ const newline = text.indexOf("\n", index + 1);
292
+ index = newline === -1 ? text.length : newline + 1;
293
+ } else if (text.startsWith('"""', index)) {
294
+ state.mode = "multiline-basic";
295
+ index += 3;
296
+ } else if (text.startsWith("'''", index)) {
297
+ state.mode = "multiline-literal";
298
+ index += 3;
299
+ } else if (text[index] === '"') {
300
+ state.mode = "basic";
301
+ index += 1;
302
+ } else if (text[index] === "'") {
303
+ state.mode = "literal";
304
+ index += 1;
305
+ } else if (text[index] === "[") {
306
+ state.arrayDepth += 1;
307
+ index += 1;
308
+ } else if (text[index] === "]" && state.arrayDepth) {
309
+ state.arrayDepth -= 1;
310
+ index += 1;
311
+ } else if (text[index] === "{") {
312
+ state.inlineDepth += 1;
313
+ index += 1;
314
+ } else if (text[index] === "}" && state.inlineDepth) {
315
+ state.inlineDepth -= 1;
316
+ index += 1;
317
+ } else if (!state.arrayDepth && !state.inlineDepth && (text[index] === "," || text[index] === "}")) {
318
+ break;
319
+ } else {
320
+ index += 1;
321
+ }
322
+ }
323
+ return index;
324
+ }
325
+
326
+ function parseInlineTomlTable(text, open, path, tables = []) {
327
+ if (text[open] !== "{") return null;
328
+ const table = { close: -1, entries: [], open, path };
329
+ tables.push(table);
330
+ let index = skipTomlTrivia(text, open + 1);
331
+
332
+ while (index < text.length && text[index] !== "}") {
333
+ const start = index;
334
+ const key = parseKeyPath(text, index);
335
+ if (!key) return null;
336
+ index = skipWhitespace(text, key.index);
337
+ if (text[index] !== "=") return null;
338
+ const valueStart = skipTomlTrivia(text, index + 1);
339
+ const entryPath = [...path, ...key.path];
340
+ const nested = text[valueStart] === "{"
341
+ ? parseInlineTomlTable(text, valueStart, entryPath, tables)
342
+ : null;
343
+ index = nested ? nested.close + 1 : scanInlineTomlValue(text, valueStart);
344
+ let valueEnd = index;
345
+ while (valueEnd > valueStart && /[ \t\r\n]/u.test(text[valueEnd - 1])) valueEnd -= 1;
346
+ index = skipTomlTrivia(text, index);
347
+ const comma = text[index] === "," ? index : -1;
348
+ table.entries.push({ comma, path: entryPath, start, valueEnd, valueStart });
349
+ if (comma === -1) break;
350
+ index = skipTomlTrivia(text, comma + 1);
351
+ }
352
+
353
+ if (text[index] !== "}") return null;
354
+ table.close = index;
355
+ return { close: index, table, tables };
356
+ }
357
+
358
+ function findInlineTomlTable(document, content, targetPath) {
359
+ const candidates = document.assignments
360
+ .filter((entry) => pathStartsWith(targetPath, assignmentPath(entry)))
361
+ .filter((entry) => content[entry.valueStart] === "{")
362
+ .sort((left, right) => assignmentPath(right).length - assignmentPath(left).length);
363
+
364
+ for (const entry of candidates) {
365
+ const syntax = parseInlineTomlTable(content, entry.valueStart, assignmentPath(entry));
366
+ const table = syntax?.tables
367
+ .filter((candidate) => pathStartsWith(targetPath, candidate.path))
368
+ .sort((left, right) => right.path.length - left.path.length)[0];
369
+ if (table) return { entry, syntax, table };
370
+ }
371
+ return null;
372
+ }
373
+
374
+ function updateInlineTomlTable(content, inline, targetPath, values) {
375
+ const remaining = new Map(Object.entries(values));
376
+ const edits = [];
377
+ for (const entry of inline.table.entries) {
378
+ if (!pathStartsWith(entry.path, targetPath) || entry.path.length !== targetPath.length + 1) continue;
379
+ const field = entry.path.at(-1);
380
+ if (!remaining.has(field)) continue;
381
+ edits.push({ start: entry.valueStart, end: entry.valueEnd, text: formatValue(remaining.get(field)) });
382
+ remaining.delete(field);
383
+ }
384
+ if (remaining.size) {
385
+ let insertAt = inline.table.close;
386
+ while (insertAt > inline.table.open + 1 && /\s/u.test(content[insertAt - 1])) insertAt -= 1;
387
+ const trailingComma = content[insertAt - 1] === ",";
388
+ const prefix = inline.table.entries.length ? trailingComma ? " " : ", " : "";
389
+ const pathPrefix = targetPath.slice(inline.table.path.length);
390
+ const fields = [...remaining].map(([field, value]) => (
391
+ `${formatDottedTomlPath([...pathPrefix, field])} = ${formatValue(value)}`
392
+ ));
393
+ edits.push({ start: insertAt, end: insertAt, text: `${prefix}${fields.join(", ")}` });
394
+ }
395
+ return applyEdits(content, edits);
396
+ }
397
+
398
+ function removeInlineTomlTableField(content, inline, targetPath) {
399
+ const entryIndex = inline.table.entries.findIndex((entry) => samePath(entry.path, targetPath));
400
+ if (entryIndex === -1) return content;
401
+ const entry = inline.table.entries[entryIndex];
402
+ const previous = inline.table.entries[entryIndex - 1];
403
+ const next = inline.table.entries[entryIndex + 1];
404
+ if (next) return `${content.slice(0, entry.start)}${content.slice(next.start)}`;
405
+ const start = previous?.comma === undefined || previous.comma === -1 ? entry.start : previous.comma;
406
+ return `${content.slice(0, start)}${content.slice(inline.table.close)}`;
407
+ }
408
+
409
+ function formatDottedTomlPath(path) {
410
+ return path.map(formatTomlKeySegment).join(".");
411
+ }
412
+
413
+ function insertTomlAssignments(content, section, pathPrefix, values) {
414
+ const lineEnding = detectLineEnding(content);
415
+ const lines = Object.entries(values).map(([field, value]) => (
416
+ `${formatDottedTomlPath([...pathPrefix, field])} = ${formatValue(value)}`
417
+ ));
418
+ if (!lines.length) return content;
419
+
420
+ if (!section) {
421
+ const document = parseDocument(content);
422
+ const top = content.slice(0, document.topLevelEnd).trimEnd();
423
+ const rest = content.slice(document.topLevelEnd);
424
+ const nextTop = top ? `${top}${lineEnding}${lines.join(lineEnding)}` : lines.join(lineEnding);
425
+ return `${nextTop}${rest ? `${lineEnding}${lineEnding}${rest}` : lineEnding}`;
426
+ }
427
+
428
+ const leading = section.end && content[section.end - 1] !== "\n" ? lineEnding : "";
429
+ return `${content.slice(0, section.end)}${leading}${lines.join(lineEnding)}${lineEnding}${content.slice(section.end)}`;
430
+ }
431
+
240
432
  function formatValue(value) {
241
433
  if (typeof value === "string") return JSON.stringify(value);
242
434
  if (typeof value === "boolean") return value ? "true" : "false";
243
- throw new TypeError("Managed TOML values must be strings or booleans");
435
+ if (Number.isSafeInteger(value)) return String(value);
436
+ if (typeof value === "bigint"
437
+ && value >= BigInt(Number.MIN_SAFE_INTEGER)
438
+ && value <= BigInt(Number.MAX_SAFE_INTEGER)) return String(value);
439
+ throw new TypeError("Managed TOML values must be strings, booleans, or safe integers");
244
440
  }
245
441
 
246
442
  function formatTomlKeySegment(value) {
@@ -248,6 +444,39 @@ function formatTomlKeySegment(value) {
248
444
  return /^[A-Za-z0-9_-]+$/u.test(segment) ? segment : JSON.stringify(segment);
249
445
  }
250
446
 
447
+ function extractTopLevelTomlEntries(content) {
448
+ const document = parseDocument(content);
449
+ return document.assignments
450
+ .filter(({ path, section }) => section === null && path.length === 1)
451
+ .map((entry) => ({
452
+ field: entry.path[0],
453
+ raw: content.slice(entry.start, entry.end)
454
+ }));
455
+ }
456
+
457
+ function mergeMissingTopLevelTomlEntries(content, entries) {
458
+ if (!entries.length) return content;
459
+ const lineEnding = detectLineEnding(content);
460
+ const document = parseDocument(content);
461
+ const top = content.slice(0, document.topLevelEnd);
462
+ const rest = content.slice(document.topLevelEnd);
463
+ const existingFields = new Set(
464
+ document.assignments
465
+ .filter(({ path, section }) => section === null && path.length === 1)
466
+ .map(({ path }) => path[0])
467
+ );
468
+ const missing = entries.filter(({ field }) => !existingFields.has(field));
469
+ if (!missing.length) return content;
470
+
471
+ let nextTop = top.trimEnd();
472
+ for (const entry of missing) {
473
+ nextTop = nextTop ? `${nextTop}${lineEnding}${entry.raw}` : entry.raw;
474
+ }
475
+ nextTop = nextTop.trimEnd();
476
+ if (rest && nextTop) nextTop += `${lineEnding}${lineEnding}`;
477
+ return `${nextTop}${rest}`;
478
+ }
479
+
251
480
  function updateTopLevelTomlField(content, field, value) {
252
481
  if (value === null) return removeTopLevelTomlField(content, field);
253
482
  const document = parseDocument(content);
@@ -273,9 +502,20 @@ function removeTopLevelTomlField(content, field) {
273
502
 
274
503
  function removeTomlSectionField(content, sectionName, field) {
275
504
  const document = parseDocument(content);
505
+ const sectionPath = requestedPath(sectionName);
276
506
  const section = findSection(document, sectionName);
277
507
  const entry = section && findAssignment(document.assignments, field, section);
278
- return entry ? `${content.slice(0, entry.start)}${content.slice(entry.lineEnd)}` : content;
508
+ if (entry) return `${content.slice(0, entry.start)}${content.slice(entry.lineEnd)}`;
509
+ if (!sectionPath) return content;
510
+
511
+ const fieldPath = [...sectionPath, field];
512
+ const dottedEntry = document.assignments.find((candidate) => samePath(assignmentPath(candidate), fieldPath));
513
+ if (dottedEntry) {
514
+ return `${content.slice(0, dottedEntry.start)}${content.slice(dottedEntry.lineEnd)}`;
515
+ }
516
+
517
+ const inline = findInlineTomlTable(document, content, sectionPath);
518
+ return inline ? removeInlineTomlTableField(content, inline, fieldPath) : content;
279
519
  }
280
520
 
281
521
  function applyEdits(content, edits) {
@@ -289,8 +529,50 @@ function applyEdits(content, edits) {
289
529
  function updateTomlSectionFields(content, sectionName, values) {
290
530
  const lineEnding = detectLineEnding(content);
291
531
  const document = parseDocument(content);
532
+ const sectionPath = requestedPath(sectionName);
292
533
  const section = findSection(document, sectionName);
293
534
  const entries = Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== "");
535
+ if (!section && sectionPath) {
536
+ const inline = findInlineTomlTable(document, content, sectionPath);
537
+ if (inline) return updateInlineTomlTable(content, inline, sectionPath, Object.fromEntries(entries));
538
+
539
+ const descendantEntries = document.assignments.filter((entry) => (
540
+ assignmentPath(entry).length > sectionPath.length
541
+ && pathStartsWith(assignmentPath(entry), sectionPath)
542
+ ));
543
+ if (descendantEntries.length) {
544
+ const directEntries = descendantEntries.filter((entry) => (
545
+ assignmentPath(entry).length === sectionPath.length + 1
546
+ ));
547
+ const remaining = new Map(entries);
548
+ const edits = [];
549
+ for (const entry of directEntries) {
550
+ const field = assignmentPath(entry).at(-1);
551
+ if (!remaining.has(field)) continue;
552
+ edits.push({
553
+ start: entry.valueStart,
554
+ end: entry.valueEnd,
555
+ text: formatValue(remaining.get(field))
556
+ });
557
+ remaining.delete(field);
558
+ }
559
+ let nextContent = applyEdits(content, edits);
560
+ if (remaining.size) {
561
+ const candidate = descendantEntries[0].section;
562
+ const container = candidate
563
+ && candidate.path.length < sectionPath.length
564
+ && pathStartsWith(sectionPath, candidate.path)
565
+ ? candidate
566
+ : null;
567
+ const prefix = container ? sectionPath.slice(container.path.length) : sectionPath;
568
+ const nextDocument = parseDocument(nextContent);
569
+ const nextContainer = container && findSection(nextDocument, formatDottedTomlPath(container.path));
570
+ nextContent = insertTomlAssignments(nextContent, nextContainer, prefix, Object.fromEntries(remaining));
571
+ }
572
+ return nextContent;
573
+ }
574
+ }
575
+
294
576
  if (!section) {
295
577
  const lines = [`[${sectionName}]`, ...entries.map(([field, value]) => `${field} = ${formatValue(value)}`)];
296
578
  const block = lines.join(lineEnding);
@@ -324,7 +606,9 @@ function updateTomlSectionFields(content, sectionName, values) {
324
606
 
325
607
  export {
326
608
  detectLineEnding,
609
+ extractTopLevelTomlEntries,
327
610
  formatTomlKeySegment,
611
+ mergeMissingTopLevelTomlEntries,
328
612
  parseToml,
329
613
  removeTomlSectionField,
330
614
  removeTopLevelTomlField,
package/lib/paths.mjs CHANGED
@@ -27,6 +27,7 @@ function createPaths({
27
27
  claudeSettingsPath: join(absoluteHomeDir, ".claude", "settings.json"),
28
28
  codexConfigPath: join(absoluteHomeDir, ".codex", "config.toml"),
29
29
  codexAuthPath: join(absoluteHomeDir, ".codex", "auth.json"),
30
+ codexTemplateConfigPath: join(codexTemplateRoot, "config-base.toml"),
30
31
  codexTemplateAgentsPath: join(codexTemplateRoot, "agents"),
31
32
  piAgentDir,
32
33
  piAuthPath: join(piAgentDir, "auth.json"),
@@ -7,13 +7,55 @@ import {
7
7
  import { maskSecret, pathToLabel } from "../paths.mjs";
8
8
  import { normalizeHttpBaseUrl } from "./url.mjs";
9
9
 
10
+ const claudeModelEnvKeys = [
11
+ "ANTHROPIC_MODEL",
12
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
13
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
14
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
15
+ "CLAUDE_CODE_SUBAGENT_MODEL"
16
+ ];
17
+ const defaultClaudeSettings = {
18
+ $schema: "https://json.schemastore.org/claude-code-settings.json",
19
+ attribution: {
20
+ commit: "",
21
+ pr: "",
22
+ sessionUrl: false
23
+ },
24
+ env: {
25
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
26
+ API_TIMEOUT_MS: "1000000"
27
+ },
28
+ permissions: {
29
+ allow: [
30
+ "Bash",
31
+ "Skill",
32
+ "Read",
33
+ "Agent",
34
+ "Write",
35
+ "Edit",
36
+ "Glob",
37
+ "Grep",
38
+ "WebFetch",
39
+ "WebSearch",
40
+ "NotebookEdit",
41
+ "TaskCreate",
42
+ "TaskGet",
43
+ "TaskUpdate",
44
+ "TaskList"
45
+ ],
46
+ deny: []
47
+ },
48
+ hooks: {},
49
+ alwaysThinkingEnabled: true,
50
+ language: "Chinese"
51
+ };
52
+
10
53
  function getExistingClaudeApiConfig(settings) {
11
- assertPlainObject(settings, "Claude settings");
12
54
  const env = settings.env === undefined ? {} : assertPlainObject(settings.env, "Claude settings env");
13
55
  return {
14
56
  baseUrl: env.ANTHROPIC_BASE_URL || "https://api.anthropic.com",
15
57
  key: env.ANTHROPIC_API_KEY || "",
16
- model: typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""
58
+ model: claudeModelEnvKeys.map((field) => env[field]).find((value) => typeof value === "string" && value) || ""
17
59
  };
18
60
  }
19
61
 
@@ -24,16 +66,39 @@ function buildClaudeApiSettings(settings, {
24
66
  }) {
25
67
  assertPlainObject(settings, "Claude settings");
26
68
  const env = settings.env === undefined ? {} : assertPlainObject(settings.env, "Claude settings env");
69
+ const permissions = settings.permissions === undefined
70
+ ? {}
71
+ : assertPlainObject(settings.permissions, "Claude settings permissions");
72
+ const attribution = settings.attribution === undefined
73
+ ? {}
74
+ : assertPlainObject(settings.attribution, "Claude settings attribution");
27
75
  const nextSettings = {
76
+ ...defaultClaudeSettings,
28
77
  ...settings,
78
+ attribution: {
79
+ ...defaultClaudeSettings.attribution,
80
+ ...attribution
81
+ },
29
82
  env: {
83
+ ...defaultClaudeSettings.env,
30
84
  ...env,
31
85
  ANTHROPIC_BASE_URL: normalizeHttpBaseUrl(baseUrl),
32
- ANTHROPIC_API_KEY: key,
33
- ANTHROPIC_MODEL: String(model).trim()
86
+ ANTHROPIC_API_KEY: key
87
+ },
88
+ permissions: {
89
+ ...defaultClaudeSettings.permissions,
90
+ ...permissions,
91
+ allow: Array.isArray(permissions.allow) ? permissions.allow : [...defaultClaudeSettings.permissions.allow],
92
+ deny: Array.isArray(permissions.deny) ? permissions.deny : [...defaultClaudeSettings.permissions.deny]
34
93
  }
35
94
  };
95
+ delete nextSettings.includeCoAuthoredBy;
36
96
  delete nextSettings.env.ANTHROPIC_AUTH_TOKEN;
97
+ delete nextSettings.env.DISABLE_TELEMETRY;
98
+ delete nextSettings.env.DISABLE_ERROR_REPORTING;
99
+ for (const field of claudeModelEnvKeys) {
100
+ nextSettings.env[field] = String(model).trim();
101
+ }
37
102
  return nextSettings;
38
103
  }
39
104