Claude Console:跨專案 STATUS、Claude/Codex 派工、工作階段與用量總覽(含手機 Remote Control)

A console for keeping several projects moving from inside Claude Code. One pane shows where every project stands and who it is waiting on; decisions are pressed, dispatch is one button, and Git, PR, CI and quota sit beside them, so you don't switch windows.
claude plugin marketplace add SaM-runtime/claude-console
claude plugin install console-status@claude-console
Run /console demo in any Claude Code session to see the whole pane without a registry; /console refresh goes back to real state. To connect your own projects, see Configure.
● decision needed, ◆ awaiting review, ▶ running, ↻ needs sync, ○ idle), a six-step pipeline (spec → build → sync → verify → review → release), when it was updated, and a Git summary. The next-step card on top names the one thing to handle now; with the pane closed, the band above the prompt still counts each state. See Project actions and Pipeline and project mode.2 then 1 reads 1B 2-1) and Backspace takes the last pick back. ✎ 填入決策 or ✎ 做決定 puts the answer in the prompt; Enter sends it.m opens the action menu, and every action has a single-key shortcut. When an executor finished without writing the CARD back, the console dispatches one sync by itself (autoSync) and shows its progress step by step. An executor that stopped to ask you is answered from the console (↩ 回覆執行者), and the way on waits in the prompt box as a dim suggestion: Tab, then Enter (suggestNext).▸ 檔案 opens the files grouped like git status; the PR's review state and failing CI checks, with the band turning red on a CI failure. See Git, PR and CI.rm -r, force pushes, git reset --hard, DROP TABLE and more) ask first; a tool call that failed twice with the same error is not tried a third time. See Command guard and Loop guard./console runs the console (and again when it resumes); other sessions get the guards only, and a session inside a registered project switches to project mode. See Light sessions.⬆ 更新 or run /console update. See Upgrade.Every change, version by version, is in CHANGELOG.md.
claude-console is a local, multi-project Claude Code mod. One console session owns specification, supervision, and review gates across registered projects; the operator supplies decisions and performs the final release approval.
The console-status mod reads compact STATUS cards and managed executor state, then exposes trigger-style actions. Verification, dispatch, STATUS sync, and review start from one button and report their outcome in the activity feed. The panel never performs a release or formal-environment change.
Dispatch is selectable. claude is the default executor and uses Claude Code's native background agents, so it needs no Codex account. Choose codex to use Codex Companion when Claude quota is limited.
Panel refreshes query local files and CLIs, then update mod-owned managed-state records when observations change. Refresh itself does not request a model. A decision draft spends Claude quota only when the operator submits it. The current UI is Traditional Chinese.
sh probes; no PowerShell needed)executor: codex only: Node.js, Codex Companion and a Codex account. Windows probes need PowerShell and the Codex desktop app; macOS probes need the codex CLI on PATH (Homebrew prefixes are added automatically)claude plugin marketplace add SaM-runtime/claude-console
claude plugin install console-status@claude-console
For this checkout:
claude plugin marketplace add .
claude plugin install console-status@claude-console
Marketplace management is covered by the official plugin marketplace documentation.
claude plugin configure accepts a JSON object whose values are single-line strings. Unspecified options retain their current values.
@'
{
"registryPath": "C:\\Projects\\console\\projects-scope.md",
"dispatchSettingsPath": "~/.claude/handoffs/dispatch.json",
"claudeSessionsPath": "~/.claude/handoffs/claude-sessions.json",
"executor": "claude",
"defaultModel": "",
"defaultEffort": "",
"companionScript": "",
"companionStateDir": "",
"companionStateRoots": "",
"modelsCachePath": "~/.codex/models_cache.json",
"codexFallback": "ask",
"codexMinQuotaPercent": "10"
}
'@ | claude plugin configure console-status@claude-console --values-stdin
| Key | Purpose | Default |
|---|---|---|
registryPath | Markdown registry mapping names to STATUS files | ~/.claude/handoffs/projects-scope.md |
dispatchSettingsPath | Shared executor, model, and effort file | ~/.claude/handoffs/dispatch.json |
claudeSessionsPath | Managed Claude session mapping | ~/.claude/handoffs/claude-sessions.json |
executor | Fallback executor when settings are missing or invalid | claude |
defaultModel / defaultEffort | Fallback model settings; empty uses the executor's native default | Empty |
companionScript | Codex Companion script used by executor: codex; empty or stale paths auto-resolve to the newest installed Codex plugin | Empty |
companionStateDir | Legacy companion state root, also passed to Codex preflight | System Temp codex-companion directory |
companionStateRoots | JSON array encoded as a string; overrides Codex job roots | Plugin data first, then legacy Temp |
modelsCachePath | Codex model and effort cache | ~/.codex/models_cache.json |
codexFallback | What to do when Codex is unusable: ask, claude, or off | ask |
codexMinQuotaPercent | Codex quota (percent remaining) below which the fallback applies | 10 |
gitProbe | on: each project's Git state (branch, uncommitted, unpushed) plus its pull request and CI checks through gh; git: local Git only; off: neither (see Git, PR and CI) | on |
commandGuard | ask: an irreversible shell command asks you first (see Command guard); deny: refuse them; off: no guard | ask |
loopGuard | on: a tool call that fails twice with the same arguments and the same error tells the model not to try a third time (see Loop guard); off: no note | on |
cacheHint | on: show the console's prompt-cache countdown and re-write cost (see Prompt cache and cost); off: hide | on |
cacheTtl | auto (the TTL the API reports in the session transcript's usage; 5 minutes until one is seen), 5m or 1h | auto |
cacheWritePrice | USD per million cache-write tokens for the estimate; empty uses the model's list price | Empty |
autoSync | on: when finished executor work left the CARD behind (待同步), the console dispatches one sync by itself, once per job, and never retries one that fails or writes nothing. off: only the 同步 button syncs | on |
suggestNext | on: the way on (a decision, a reply to an executor that asked, a gate, a sync, a continue) waits in the prompt box as a dim suggestion, Tab to take (see Tab for the next step); off: leave the box to Claude Code's own suggestions | on |
activation | auto: only a session where you ran /console runs the console, and it starts again when that session resumes; other sessions get the command guard only (see Light sessions). always: every session | auto |
projectMode | auto: a session opened inside a registered project switches to project mode (see Pipeline and project mode); off: always the multi-project console | auto |
Paths beginning with ~ expand on Windows, macOS, and Linux. Run /reload-plugins or start another Claude Code session after plugin configuration changes. See the registry example.
The canonical file is ~/.claude/handoffs/dispatch.json (dispatchSettingsPath). It holds the global executor, model, and effort, plus optional per-project overrides:
{
"executor": "claude", "model": "", "effort": "",
"projects": {
"D:/Projects/api": { "executor": "codex", "model": "", "effort": "high" },
"D:/Projects/legacy-portal": { "executor": "manual" }
}
}
The 0.1 flat file without projects keeps working unchanged. When dispatch.json is missing, the mod reads the legacy codex-dispatch.json next to it (read-only; a legacy file without executor means codex). Every write goes to dispatch.json.
Each project resolves its executor in this order:
projects["<root>"] in dispatch.json. Keys are project roots; Windows drive and UNC paths match case-insensitively.Executor column of the registry table (claude, codex, manual, or blank). Registries without the column parse as before.executor field.Model and effort follow the same chain. A project whose executor differs from the global one does not inherit the global model and effort, because those values belong to the other executor.
manual means the panel never dispatches that project: Sync and Continue are hidden, the card shows a handoff note, and CARD, verification, decisions, and gates still work.
In the expanded project card and the right-click menu, the 執行者 row offers 沿用 (inherit, naming what it inherits and from where), claude, codex and manual; one press picks one, and the current choice is bracketed. The pane updates at once; the refresh that follows runs in the background. /console project executor|model|effort <value|inherit> <project name> sets the same fields, and /console project lists the effective settings.
A project may hold jobs from both executors (after an executor change or a Claude fallback). Job listing merges both executors for every project; any running or queued job from either executor marks the project RUNNING and blocks a second dispatch.
The executor, model, and effort controls beneath the panel title are plain Buttons. A press spreads that setting's choices out on the line below, with the current one in brackets and 預設 for the executor's own default; pressing a choice writes the file and shows a toast, and pressing the current one, ✕ or the label again closes the line. Efforts follow the chosen model. Without a readable Codex model cache the model line offers only 預設 and the current model, and says to use /console model <name>. The same Buttons render on mobile without Client support. Settings apply to the next dispatch; each running task keeps its requested values.
Commands show or set the same values:
/console executor
/console executor claude
/console model
/console model <name>
/console effort <level>
Use /console model "" or /console effort "" to restore the selected executor's default. A missing, malformed, or unreadable file falls back to executor, defaultModel, and defaultEffort.
同步 STATUS only writes the CARD and its history, so it can run on a cheaper model than the work itself. Nothing is set by default: until you choose one, a sync uses the same model and session as any other dispatch. Set it per executor, because Claude and Codex accounts have different models:
{
"executor": "claude", "model": "opus", "effort": "high",
"sync": { "claude": { "model": "haiku" }, "codex": { "model": "luna", "effort": "low" }, "session": "fresh" },
"projects": { "D:/Work/Beta": { "sync": { "session": "resume" } } }
}
sync fields win over the global ones.session: fresh starts a new small session that reads STATUS.md, git log/git status and the executor digest, and is told to write only what those show. resume continues the project's session, as before. Left out, a Claude sync with its own model goes fresh (a cheap model resuming the long session would re-cache all of it at the new model's price), and Codex stays on resume, because the next continue's --resume-last would pick up a fresh sync's thread instead of the work's. A row whose executor stopped to ask always resumes that session.(用 haiku・新 session) and the 同步 line claude(haiku・新 session) 寫回中. If the sync model fails to launch, the console syncs again at once with the normal model and tells you; if a sync on it fails or ends without writing the CARD, a toast says so. In both cases later syncs use the normal model until you set the sync model again.同步模型 control beneath the title picks the sync model of the global executor (同派工 clears it); 新 session follows it when a sync will start one, and its choices line names the executor, e.g. 同步模型(claude). /console sync-model [claude|codex] <name>, /console sync-effort [claude|codex] <level> and /console sync-session fresh|resume set the same fields; "" clears one, and /console sync-model alone shows them.This is the default. It uses native Claude Code background agents and does not require Codex Companion or a Codex account.
The mod keeps a project-to-session mapping and the latest 20 managed job records per project in claudeSessionsPath. A first task starts in native background mode with the project as its working directory; later tasks resume the mapped full session ID. Local help from Claude Code 2.1.289 documents --bg, --resume, and --continue. The mod uses --bg with a unique --name, confirms the returned short ID through the agents list, and uses the saved full session ID for resume. It does not use --continue --bg, because that combination selects the most recent session for the working directory instead of the explicitly managed session.
claude agents --json --all --cwd <project-root> supplies active and completed agents. The mod accepts only background agents whose cwd exactly matches the project, uses their short id, full sessionId, name, state, status, and waitingFor, and reads output with claude logs <id>. A launch must resolve by its unique name to exactly one full session UUID (the existing UUID when resuming) before the mapping is saved; an ambiguous launch stays unresolved and blocks another dispatch. A later refresh can recover a uniquely named launch. Blocked or waiting agents remain running, and an absent agent does not imply completion. The local sample showed working and blocked states with busy, idle, and waiting statuses; the documented terminal states done, failed, and stopped end the managed job.
The sessions file is mod-owned state. Malformed content fails closed instead of discarding the saved session identity. Writes are serialized inside one mod process, so keep one console process as its writer; simultaneous writes from separate processes are not guaranteed atomic. Claude dispatch adds no permission-bypass flag and inherits the normal Claude Code permission flow. A background agent that finished its turn on a question is answered from the console with ↩ 回覆執行者 (see Project actions); attach to one that waits on a permission prompt to handle the approval.
Model Buttons offer the CLI aliases fable, opus, sonnet and haiku; /console model <name> also accepts a free-form value. Effort options are low, medium, high, xhigh, and max. Empty values leave both choices to Claude Code.
Claude Code's agent view is the upstream interface for inspecting and controlling background agents. The CLI reference owns current command and flag behavior; this mod stores only the managed session identity needed to continue a project.
Use this optional executor when Claude quota is limited. Configure companionScript and ensure Codex Companion can reach its account before dispatching.
Codex model order comes from models_cache.json (models[].slug). Efforts use supported_reasoning_levels[].effort, falling back to low, medium, high, and xhigh. An unavailable cache leaves the current values visible; set a model with /console model <name>. Empty model or effort values omit their flags and use Codex defaults.
Jobs are read from ~/.claude/plugins/data/codex-openai-codex/state before the legacy Temp root. Duplicate IDs use the newest updatedAt; the state timestamp is the fallback, and ties keep the first root. Override the list with, for example, "companionStateRoots": "[\"~/jobs/current\",\"~/jobs/legacy\"]". This setting does not change the Codex quota or preflight probes.
With the Codex executor selected, refresh runs the bundled companion and broker preflight on its probe interval. Quota comes from the newest locally available Codex record and may be stale. Dispatch acceptance only means the companion accepted the job. Completion and review still require STATUS evidence and the configured local verification.
When companionScript is empty, or the preflight reports the configured file MISSING (the Codex plugin installs each version into its own cache folder, so a pinned path goes stale after an update), the mod picks the newest semantic version among ~/.claude/plugins/installed_plugins.json install paths for codex@openai-codex and ~/.claude/plugins/cache/openai-codex/codex/<version>/scripts/codex-companion.mjs that actually contains the script. The footer shows the path in use, marked 自動選用, with a warning when the configured path was stale.
Before each Codex dispatch the mod checks the latest probes:
codexMinQuotaPercent remaining (lowest window), orA quota reading older than six hours is shown but treated as unknown; it never triggers the fallback by itself. Then:
codexFallback | Behavior |
|---|---|
ask (default) | Does not dispatch. A toast and the project card state why and offer ⇢ 改用 Claude 派工, which sends the same prompt to Claude. |
claude | Sends the same prompt to the Claude executor and records fallbackFrom: "codex" and the reason on the job. |
off | 0.1 behavior: always dispatch to Codex. |
Claude stand-in jobs show codex→claude in the task list. They use Claude's own model and effort only when the global executor is claude; otherwise Claude Code defaults.
Paste this into the user CLAUDE.md so a console that dispatches by hand follows the same file:
## Dispatch settings
Before dispatching to any executor, read ~/.claude/handoffs/dispatch.json
(if it is missing, read ~/.claude/handoffs/codex-dispatch.json; never write it).
- Global: "executor" (claude | codex), "model", "effort".
- Per project: "projects"["<project root>"] may set "executor"
(claude | codex | manual), "model", "effort". Match the root case-insensitively
on Windows. Precedence: projects entry > registry Executor column > global.
- Pass each nonempty model/effort as one --model / --effort argument; omit empty ones.
- "manual" means: never dispatch that project; prepare a handoff for the user instead.
Each STATUS file has one machine-readable block delimited by <!-- CARD --> and <!-- /CARD -->. Keep it to ten lines or fewer. The parser recognizes the Traditional Chinese keys in the template, including 更新, 狀態, 驗證, 等使用者, 下一步, and 關卡.
Set 關卡 to 無, spec:…, review:…, or release:…. Unknown nonempty gate text also blocks continuation. A practical local path for a Git project is .console/STATUS.md, excluded through that project's .git/info/exclude when it should remain local.
The right-click menu (or m on the keyboard for the row under the cursor; Esc closes it) and expanded project cards expose the same actions. With the menu open, digit keys answer the pending decisions in order (2 then 1 reads 1B 2-1) and Backspace takes the last pick back; 做決定 and d fill the composer with the picks made so far. The menu shows the project's state and pipeline, what is running with its elapsed time and latest output line, and the CARD's decision, gate and next step. A dispatch that is blocked by running work is stated as 派工鎖定:… instead of a disabled button. Mobile uses card Buttons. Every trigger shows an immediate toast, shows elapsed seconds and rejects duplicate activation while running, then writes its result to the activity feed.
| Action | Availability | Behavior |
|---|---|---|
| ▶ Run verification | CARD has 驗證 | Runs in the project root with a five-minute timeout; no model quota |
| ⇢ Sync STATUS | SYNC, executor not manual | Dispatches the project's executor to update only CARD and history; autoSync: on does this by itself once per finished job |
| ⇢ Continue | IDLE, with a next step and no decision or gate; executor not manual | Requires a second press within six seconds (the pane shows the next step it will dispatch), then dispatches the project's executor |
| ↩ 回覆執行者 | A Claude ex
hooks/register.tsx 2751 lines1// console-status: the Claude console's live overview, with selectable background executors.
2// Reads the registry, project STATUS cards and the selected executor's job state.
3// Band above the prompt + `/console` pane; toasts on changes. Shows on phones via Remote Control.
4import { atom, read, update } from 'claude-code'
5import type { Register, PluginOptions } from 'claude-code'
6import { resolveConfig, resolveFallbackOptions, legacyDispatchPath, activationMode, suggestNextMode } from './config'
7import type { ConsoleConfig } from './config'
8import { codexHealth, isActiveJob } from './logic'
9import { parseModels, modelOptions, nextOption, effortOptions, readSettingsFiles, effectiveDispatch, setProjectOverride, executorSignature, projectOverride, syncDispatch, setSyncSetting } from './dispatch'
10import type { DispatchSettings, ProjectOverride } from './dispatch'
11import { createExecutor, listWorkspaceJobs, sharedAgents } from './executors'
12import type { ExecutorDeps, ExecutorJob, ExecutorKind, DispatchOptions } from './executors'
13import { actionKinds, actionLabel, askingSession, commandTarget, decisionProject, replyPrompt, reviewPrompt, suggestedPrompt, COMMAND_ACTIONS, dispatchBlockReason, dispatchPrompt, freshSession, freshSyncPrompt, gatePrompt, workSignature, confirmationMatches, verificationArgs, verificationResult, outputTail, isManual, CONTINUE_CONFIRM_MS, VERIFY_CONFIRM_MS, verifySignature, verifyTrusted, reviewTurnMatches, staleGate, stalePendingReason } from './actions'
14import { resolveCompanion } from './companion'
15import type { CompanionResolution } from './companion'
16import { decideCodexDispatch } from './fallback'
17import { isWindowsOs, openFallbackArgs, preflightArgs, quotaArgs } from './platform'
18import { stateFormatIssues, stateFormatWarning } from './jobs'
19import { pipeline, projectForCwd, projectModeSection, progressContext, progressSignature } from './pipeline'
20import type { Pipeline } from './pipeline'
21import { pipelineLine, pipelineParts, pipelineText } from './pipeline-view'
22import { FETCH_STALE_MS, agoText, commitLine, fileGroups, fileKind, gitBadge, gitFilesArgs, gitLine, gitLogArgs, gitNumstatArgs, gitProbeMode, gitShowArgs, gitSignature, gitStatusArgs, linesText, parseGitFiles, parseGitLog, parseGitShow, parseGitStatus, parseNumstat, parseNumstatFiles, parsePrView, prLine, prViewArgs } from './git'
23import type { PrInfo, CacheClock, GitCommit, GitDetail, GitInfo, UpdateInfo } from '../types'
24import { CACHE_WARN_MS, cacheChip, compactedClock, cacheTtlOption, cacheView, cacheWarning, hitRate, hitText, leftText, learnTtl, priceOverride, tokensText, transcriptCache, transcriptPathFor, ttlLabel, usd } from './cache'
25import { limitPace } from './pace'
26import { LOOP_NOTE, LoopMemory, loopKey, loopMode } from './loop'
27import type { TtlSource } from './cache'
28import type { CacheTtl } from './cache'
29import { commandPreview, dangerReason, guardMode } from './guard'
30import { compareVersions, gitBranchArgs, gitPullArgs, gitTopArgs, hasUpdate, LATEST_MANIFEST_URL, localFolder, manifestVersion, marketplaceUpdateArgs, pluginListArgs, UPDATE_CHECK_MS, updateArgs, updateOutcome, versionLine } from './updater'
31
32import type { Project, Snapshot, ActionKind, VerificationResult, PendingAction } from '../types'
33import { jobWarnings, waitingNames, parseGate, parseCodexQuota, taskMeta, runLine, hasAsk, parseAsk, askSummary, splitClauses, decisionAnswer, pickDecision, battery, blockedLines, resetText, nextProject, buildProject, counts, demoSnapshot, diffToasts, displayWidth, events, limitHelp, limitName, meter, next, parseRegistry, projectRoot, relevantBlocked, relevantCodex, rows, selectionContext, ROTATE_PERCENT } from './logic'
34import type { Agent, State } from './logic'
35import { projectColumnWidth, demoEvents } from './logic'
36import { batteryBody, METER } from './battery'
37import { feedBody } from './feed'
38import { trackRowChanges } from './presentation'
39import { layoutBand } from './band'
40import { advanceSync, cardStamp, autoSyncJobs, autoSyncMode, bandSync, isSyncEnded, syncChip, syncStatus, syncStepsText } from './sync'
41import type { SyncProgress, ExecutorDigest } from '../types'
42import { DIGEST_NONE, DIGEST_TIMEOUT, PROMPT_DIGEST_MS, codexDigestLines, digestContext, digestSource, loadDigest } from './digest'
43import type { DigestCache, DigestIo, DigestResult } from './digest'
44
45const dispatchRevision = atom({ plugin: 'console-status', key: 'dispatchRevision' } as const, 0)
46
47const PANE = 'console-status'
48// What the plugins beneath answered for the band draws nothing: no tree, or Boxes and Texts holding none.
49const isEmptyTree = (node: any): boolean =>
50 node === null || node === undefined || node === false || node === '' ||
51 ((node.type === 'Box' || node.type === 'Text') && (node.children ?? []).every(isEmptyTree))
52type GlobalField = 'executor' | 'model' | 'effort'
53/** The pane's pickers: the global dispatch fields, and the sync model for the global executor. */
54type PickerField = GlobalField | 'sync'
55const PICKER_LABEL: Record<PickerField, string> = { executor: '派工', model: '模型', effort: '強度', sync: '同步模型' }
56const SOURCE_LABEL: Record<string, string> = { pane: '面板覆寫', registry: '登錄表', global: '全域' }
57const TICK_MS = 60_000
58const SLOW_MS = 5 * 60_000
59const snapshot = atom({ plugin: 'console-status', key: 'snapshot' } as const, null)
60const isDemo = atom({ plugin: 'console-status', key: 'isDemo' } as const, false)
61const isPaneOpen = atom({ plugin: 'console-status', key: 'isPaneOpen' } as const, false)
62const isBandHidden = atom({ plugin: 'console-status', key: 'isBandHidden' } as const, false)
63const isDetail = atom({ plugin: 'console-status', key: 'isDetail' } as const, false)
64const isPlain = atom({ plugin: 'console-status', key: 'isPlain' } as const, false)
65const selected = atom({ plugin: 'console-status', key: 'selected' } as const, null)
66const cursor = atom({ plugin: 'console-status', key: 'cursor' } as const, -1)
67const feedAtom = atom({ plugin: 'console-status', key: 'feed' } as const, [])
68const isRefreshing = atom({ plugin: 'console-status', key: 'isRefreshing' } as const, false)
69const hovered = atom({ plugin: 'console-status', key: 'hovered' } as const, null)
70const menuFor = atom({ plugin: 'console-status', key: 'menuFor' } as const, null)
71/** The project (statusPath) whose Git file view is open, and the views read so far. */
72const gitOpen = atom({ plugin: 'console-status', key: 'gitOpen' } as const, null)
73const gitViews = atom({ plugin: 'console-status', key: 'gitViews' } as const, {})
74const decisionPicks = atom({ plugin: 'console-status', key: 'decisionPicks' } as const, {})
75/** Which dispatch setting has its choices spread out under the pane header, if any. */
76const dispatchPicker = atom({ plugin: 'console-status', key: 'dispatchPicker' } as const, null)
77const pendingActions = atom({ plugin: 'console-status', key: 'pendingActions' } as const, {})
78const continueConfirmations = atom({ plugin: 'console-status', key: 'continueConfirmations' } as const, {})
79const verificationResults = atom({ plugin: 'console-status', key: 'verificationResults' } as const, {})
80/** statusPath → the 驗證 command the user approved; mirrored from `$.store` so drawing can read it. */
81const trustedVerify = atom({ plugin: 'console-status', key: 'trustedVerify' } as const, {})
82const TRUST_KEY = 'trustedVerify'
83/** `/console mode`: this session's choice over the `projectMode` option. */
84const modeOverride = atom({ plugin: 'console-status', key: 'modeOverride' } as const, 'auto')
85/** Where this Claude Code session runs; a registered project here turns on project mode. */
86let sessionCwd: string | null = null
87/** The progress last attached to a prompt in project mode, so an unchanged one is not repeated. */
88let lastProgress = ''
89const actionPulse = atom({ plugin: 'console-status', key: 'actionPulse' } as const, 0)
90const reviewRequests = atom({ plugin: 'console-status', key: 'reviewRequests' } as const, {})
91const fallbackOffers = atom({ plugin: 'console-status', key: 'fallbackOffers' } as const, {})
92/** statusPath → where its 同步 STATUS dispatch is (派工 → 執行 → 寫回), advanced on each refresh. */
93const syncProgress = atom({ plugin: 'console-status', key: 'syncProgress' } as const, {})
94/** statusPath → the 執行者 section of an open project card. */
95const executorDigests = atom({ plugin: 'console-status', key: 'executorDigests' } as const, {})
96/** Parsed transcript tails, kept per path while mtime and size stay; and where each session's transcript was found. */
97const digestCache: DigestCache = new Map()
98const transcriptPaths = new Map<string, string>()
99const digestLoads = new Set<string>()
100/** statusPath → the last digest read for it, which a prompt carries when a fresh read takes too long. */
101const lastDigests = new Map<string, string[]>()
102/** When this plugin lifetime began (its first refresh); a reload starts a new one. */
103let lifetimeStart = 0
104/** Finished jobs auto-sync already dispatched a sync for (in `$.store`, so once per job across sessions). */
105const AUTO_SYNCED_KEY = 'autoSynced'
106const AUTO_SYNCED_MAX = 200
107/** The same, for this process: holds even when the store refuses a write. */
108const autoSynced = new Set<string>()
109/**
110 * Sync models (`executor:model|effort`) that failed to launch, failed, or wrote nothing: later syncs use the normal model
111 * until the sync setting is changed. In `$.store`, mirrored here for when the store refuses a write.
112 */
113const SYNC_MODEL_FAILED_KEY = 'syncModelFailed'
114const syncModelFailed = new Map<string, string>()
115async function failedSyncModels($: any): Promise<Map<string, string>> {
116 const stored: unknown = await Promise.resolve().then(() => $.store.get(SYNC_MODEL_FAILED_KEY)).catch(() => null)
117 const all = new Map(syncModelFailed)
118 if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
119 for (const [key, reason] of Object.entries(stored as Record<string, unknown>)) if (typeof reason === 'string') all.set(key, reason)
120 }
121 return all
122}
123async function markSyncModelFailed($: any, key: string, reason: string) {
124 syncModelFailed.set(key, reason)
125 const all = await failedSyncModels($)
126 await Promise.resolve().then(() => $.store.set(SYNC_MODEL_FAILED_KEY, Object.fromEntries([...all].slice(-20)))).catch(() => {})
127}
128async function clearSyncModelFailures($: any) {
129 syncModelFailed.clear()
130 await Promise.resolve().then(() => $.store.set(SYNC_MODEL_FAILED_KEY, {})).catch(() => {})
131}
132const syncModelLabel = (key: string) => key.replace(/^[^:]*:/, '').replace(/\|$/, '').replace('|', ' · ')
133/** While a job or a sync is under way the console looks every 20 s instead of every minute. */
134const FAST_TICK_MS = 20_000
135/**
136 * `executor:id` → what the console dispatched it for. Codex's state file may not keep the prompt the
137 * kind is read from, and a sync must never be taken for work that still needs a sync.
138 */
139const DISPATCH_KINDS_KEY = 'dispatchKinds'
140const DISPATCH_KINDS_MAX = 200
141let dispatchKinds: Map<string, 'sync' | 'continue'> | null = null
142async function loadDispatchKinds($: any): Promise<Map<string, 'sync' | 'continue'>> {
143 if (dispatchKinds) return dispatchKinds
144 const stored: unknown = await Promise.resolve().then(() => $.store.get(DISPATCH_KINDS_KEY)).catch(() => null)
145 const entries = Array.isArray(stored) ? stored.filter((item): item is [string, 'sync' | 'continue'] =>
146 Array.isArray(item) && typeof item[0] === 'string' && (item[1] === 'sync' || item[1] === 'continue')) : []
147 return dispatchKinds ??= new Map(entries)
148}
149async function rememberDispatchKind($: any, key: string, kind: 'sync' | 'continue') {
150 const kinds = await loadDispatchKinds($)
151 kinds.delete(key)
152 kinds.set(key, kind)
153 while (kinds.size > DISPATCH_KINDS_MAX) kinds.delete(kinds.keys().next().value as string)
154 await Promise.resolve().then(() => $.store.set(DISPATCH_KINDS_KEY, [...kinds])).catch(() => {})
155}
156let fastTimer: { cancel(): void } | undefined
157const cacheClock = atom({ plugin: 'console-status', key: 'cacheClock' } as const, null)
158/** Bumped every 15 s while a cache clock runs, so the countdown redraws without a full refresh. */
159const cacheTick = atom({ plugin: 'console-status', key: 'cacheTick' } as const, 0)
160const isTurnRunning = atom({ plugin: 'console-status', key: 'isTurnRunning' } as const, false)
161/** Installed vs. latest version and an update in progress; kept across a reload so the result shows after it. */
162const updateInfo = atom({ plugin: 'console-status', key: 'updateInfo' } as const, null)
163const CACHE_TTL_KEY = 'cacheTtl'
164/** The cache clock (its `at`) the expiry warning already fired for. */
165let cacheWarnedFor = -1
166let cacheTimer: { cancel(): void } | undefined
167/** When the countdown last redrew: every 15 s, every second in the last minute. */
168let cacheDrawnAt = 0
169/** Failed tool calls, for the loop guard; a new session forgets them. */
170const loops = new LoopMemory()
171/** This session's transcript, as a classic hook names it (or as Claude Code lays it out, until one does). */
172let transcriptPath: string | null = null
173let updateTimer: { cancel(): void } | undefined
174let updateCheck: Promise<UpdateInfo | null> | null = null
175/** The newest version already announced by a toast (kept in `$.store`, so once per version). */
176const UPDATE_NOTIFIED_KEY = 'updateNotified'
177const RELOAD_DELAY_MS = 1500
178const RELOAD_WATCHDOG_MS = 20_000
179/** statusPath → the action holding its row, with a token so a lock let go of early is not released again later by its owner. */
180const actionLocks = new Map<string, { kind: ActionKind; token: number }>()
181let lockSeq = 0
182const earlyReviewStarts = new Map<string, string>()
183let selectionClaim: string | null = null
184
185// Slow probes (spawn processes) are cached between ticks; they reset on a reload, which is fine.
186let codex = '…'
187let agents: Agent[] = []
188let codexQuotaText = ''
189let companion: CompanionResolution | null = null
190let lastSlow = 0
191/** Last `gh pr view` per project root: re-asked on the slow interval, every tick while CI runs, or on a branch change. */
192const prCache = new Map<string, { at: number; branch: string; pr: PrInfo | null }>()
193/** Recent commits per project root, read again only when HEAD moves. */
194const commitCache = new Map<string, { head: string; commits: GitCommit[] }>()
195let demoActive = false
196let dataGeneration = 0
197let refreshOwner: { generation: number; queued: boolean } | null = null
198let refreshTimer: { cancel(): void } | undefined
199/**
200 * Whether this session runs the console (polling, band, project mode, cache hints, update check).
201 * Under `activation: auto` a session stays light until `/console` is used in it; the command
202 * guard runs either way.
203 */
204let consoleActive = false
205let sessionStartCwd: string | null = null
206const ACTIVE_SESSIONS_KEY = 'consoleSessions'
207const ACTIVE_SESSIONS_MAX = 50
208let displayWrites: Promise<void> = Promise.resolve()
209let demoContext: { config: ConsoleConfig; dispatch: Awaited<ReturnType<typeof readDispatch>> } | null = null
210const DISCARDED_REFRESH = Symbol('discarded refresh')
211let settingsWrite: Promise<void> = Promise.resolve()
212let launchGeneration = 0
213const acceptedLaunches = new Set<string>()
214const unpublishedLaunches = new Map<string, { root: string; job: ExecutorJob }>()
215const jobKey = (job: { executor?: ExecutorKind; id: string }) => `${job.executor}:${job.id}`
216const workspaceKey = (root: string) => /^[a-z]:\/|^\/\//i.test(root) ? root.toLowerCase() : root
217
218// A mode transition waits for any older publication, then replaces every visible data channel.
219async function publishDisplay(write: () => Promise<void>) {
220 const previous = displayWrites
221 let release = () => {}
222 displayWrites = new Promise<void>(resolve => { release = resolve })
223 await previous
224 try { await write() } finally { release() }
225}
226
227async function demoEnabled($: any) {
228 return demoActive || await read($, isDemo)
229}
230
231async function refreshDemo($: any, generation = dataGeneration, executor?: ExecutorKind) {
232 const now = await $.clock.now()
233 await publishDisplay(async () => {
234 if (generation !== dataGeneration || !await demoEnabled($)) return
235 const previous = await read($, snapshot)
236 await update($, snapshot, () => ({ ...demoSnapshot(now), executor: executor ?? demoContext?.dispatch.settings.executor ?? previous?.executor ?? 'claude' }))
237 await update($, feedAtom, () => demoEvents(now))
238 })
239}
240
241async function workspaceJobs(kind: ExecutorKind, deps: ExecutorDeps, config: ConsoleConfig, root: string): Promise<ExecutorJob[]> {
242 const jobs = await listWorkspaceJobs(kind, deps, config, root)
243 const key = workspaceKey(root)
244 for (const [id, pending] of unpublishedLaunches) {
245 if (pending.root !== key) continue
246 if (jobs.some(job => jobKey(job) === jobKey(pending.job))) unpublishedLaunches.delete(id)
247 else jobs.push(pending.job)
248 }
249 // Every active job of either executor counts, so RUNNING never misses one. Finished jobs count
250 // for the project's own executor and for Claude jobs that stood in for a Codex dispatch.
251 return jobs.filter(job => job.executor === kind || isActiveJob(job) || !!job.fallbackFrom)
252}
253
254const REFRESH_CONCURRENCY = 4
255/** `work` over `items` with at most `limit` running at once; results keep the input order. */
256async function mapLimit<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]> {
257 const results = new Array<R>(items.length)
258 let next = 0
259 const lane = async () => { while (next < items.length) { const index = next++; results[index] = await work(items[index]!, index) } }
260 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane))
261 return results
262}
263
264/** Config with the companion script actually in use (configured, or auto-resolved when stale). */
265const withCompanion = (config: ConsoleConfig): ConsoleConfig => companion?.path ? { ...config, companionScript: companion.path } : config
266
267async function paths($: any, options: PluginOptions) {
268 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
269 const local = ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/')
270 const temp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
271 return { home, config: resolveConfig(options, home, local, temp) }
272}
273
274/** One project's executor digest: from the cache while its transcript is unchanged; never throws. */
275async function projectDigest($: any, options: PluginOptions, p: Project): Promise<DigestResult> {
276 try {
277 // A project whose latest work ran on Codex has no Claude transcript to read; an older Claude session is not its latest.
278 const source = digestSource(p.executor, p.tasks)
279 if (source.kind === 'codex') {
280 if (!source.task) return { kind: 'none' }
281 const lines = codexDigestLines(source.task, await $.clock.now())
282 lastDigests.set(p.statusPath, lines)
283 return { kind: 'ready', lines }
284 }
285 const { home, config } = await paths($, options)
286 const claudeDir = ((await $.env.get('CLAUDE_CONFIG_DIR').catch(() => '')) || `${home}/.claude`).replace(/\\/g, '/')
287 const root = projectRoot(p.statusPath)
288 const windows = /^[a-z]:\//i.test(root) || isWindowsOs(await $.env.get('OS').catch(() => ''))
289 const io: DigestIo = {
290 windows,
291 read: path => $.fs.read(path), exists: path => $.fs.exists(path), stat: path => $.fs.stat(path),
292 list: path => $.fs.list(path), run: (argv, init) => $.process.run(argv, init),
293 }
294 const result = await loadDigest(io, { root, claudeDir, sessionsPath: config.claudeSessionsPath }, digestCache, transcriptPaths, await $.clock.now())
295 if (result.kind === 'ready') lastDigests.set(p.statusPath, result.lines)
296 return result
297 } catch (error) {
298 return { kind: 'error', error: error instanceof Error ? error.message : String(error) }
299 }
300}
301
302/** The block a prompt about `p` carries; a digest that cannot be read never holds the prompt back. */
303async function digestBlock($: any, options: PluginOptions, p: Project): Promise<string> {
304 // The read goes on past the wait and fills the cache for the next prompt; the prompt does not wait for it.
305 const reading = projectDigest($, options, p).then(result => ({ result }))
306 let timer: { cancel(): void } | undefined
307 const waited = new Promise<null>(resolve => { timer = $.clock.after(PROMPT_DIGEST_MS, () => resolve(null)) })
308 const outcome = await Promise.race([reading, waited])
309 timer?.cancel()
310 if (!outcome) {
311 const last = lastDigests.get(p.statusPath)
312 return last ? digestContext(last) : DIGEST_TIMEOUT
313 }
314 const { result } = outcome
315 return result.kind === 'ready' ? digestContext(result.lines) : result.kind === 'none' ? DIGEST_NONE : `執行者摘要:無法讀取(${result.error})`
316}
317
318/** The projects whose card is open: the menu's, and the focused one of the detail view. */
319async function openCards($: any): Promise<Project[]> {
320 if (await demoEnabled($)) return []
321 const s = await read($, snapshot)
322 if (!s) return []
323 const names = new Set<string>()
324 const menu = await read($, menuFor)
325 if (menu) names.add(menu)
326 if (await read($, isDetail)) {
327 const focus = (await read($, selected)) ?? rows(s)[Math.max(0, await read($, cursor))]?.full
328 if (focus) names.add(focus)
329 }
330 return s.projects.filter(p => names.has(p.name))
331}
332
333/** Reads the digest of each open card (and no other), showing 讀取中… the first time. */
334async function syncDigests($: any, options: PluginOptions) {
335 for (const p of await openCards($).catch(() => [] as Project[])) {
336 if (digestLoads.has(p.statusPath)) continue
337 digestLoads.add(p.statusPath)
338 try {
339 if (!(await read($, executorDigests))[p.statusPath]) await update($, executorDigests, values => ({ ...values, [p.statusPath]: { phase: 'loading' } }))
340 const result = await projectDigest($, options, p)
341 const shown: ExecutorDigest = result.kind === 'ready' ? { phase: 'ready', lines: result.lines } : result.kind === 'none' ? { phase: 'none' } : { phase: 'error', error: result.error }
342 await update($, executorDigests, values => JSON.stringify(values[p.statusPath]) === JSON.stringify(shown) ? values : { ...values, [p.statusPath]: shown })
343 } catch { /* the card keeps what it showed */ } finally { digestLoads.delete(p.statusPath) }
344 }
345}
346
347async function readDispatch($: any, config: ConsoleConfig) {
348 const text = await $.fs.read(config.dispatchSettingsPath).catch(() => null)
349 // Legacy codex-dispatch.json is read-only compatibility, consulted only when dispatch.json is missing.
350 const legacy = text === null ? await $.fs.read(legacyDispatchPath(config.dispatchSettingsPath)).catch(() => null) : null
351 const { settings, source } = readSettingsFiles(text, legacy, { executor: config.executor, model: config.defaultModel, effort: config.defaultEffort })
352 const cache = settings.executor === 'codex' ? await $.fs.read(config.modelsCachePath).catch(() => null) : null
353 return { settings, source, models: modelOptions(settings.executor, parseModels(cache)) }
354}
355
356let renderCache: { key: string; value: Promise<{ config: ConsoleConfig; dispatch: Awaited<ReturnType<typeof readDispatch>> }> } | null = null
357/** Paths and dispatch settings for drawing, read once per settings revision and refresh. */
358function renderDispatch($: any, options: PluginOptions, key: string) {
359 if (renderCache?.key !== key) {
360 const value = paths($, options).then(async ({ config }) => ({ config, dispatch: await readDispatch($, config) }))
361 renderCache = { key, value }
362 value.catch(() => { if (renderCache?.value === value) renderCache = null })
363 }
364 return renderCache.value
365}
366
367/** One project's override, serialized with the global settings writes. */
368async function changeProjectDispatch($: any, config: ConsoleConfig, root: string, field: Exclude<keyof ProjectOverride, 'sync'>, value: string): Promise<DispatchSettings> {
369 const previous = settingsWrite
370 let release = () => {}
371 settingsWrite = new Promise<void>(resolve => { release = resolve })
372 await previous
373 try {
374 if (/[\r\n\x00]/.test(value)) throw new Error('設定值必須是單行文字。')
375 const { settings } = await readDispatch($, config)
376 const updated = setProjectOverride(settings, root, field, value)
377 await $.fs.write(config.dispatchSettingsPath, JSON.stringify(updated, null, 2) + '\n')
378 return updated
379 } finally { release() }
380}
381
382/** Read at mutation time so changing one field preserves the other field on disk. */
383async function changeDispatch($: any, config: ConsoleConfig, field: GlobalField, value?: string): Promise<DispatchSettings> {
384 const previous = settingsWrite
385 let release = () => {}
386 settingsWrite = new Promise<void>(resolve => { release = resolve })
387 await previous
388 try { return await saveDispatch($, config, field, value) } finally { release() }
389}
390
391/** One sync setting, serialized with the other settings writes. */
392async function changeSync($: any, config: ConsoleConfig, field: 'model' | 'effort' | 'session', value: string, executor: 'claude' | 'codex'): Promise<DispatchSettings> {
393 const previous = settingsWrite
394 let release = () => {}
395 settingsWrite = new Promise<void>(resolve => { release = resolve })
396 await previous
397 try {
398 const { settings } = await readDispatch($, config)
399 const updated = setSyncSetting(settings, field, value, executor)
400 await $.fs.write(config.dispatchSettingsPath, JSON.stringify(updated, null, 2) + '\n')
401 return updated
402 } finally { release() }
403}
404
405/** The sync settings in words, with how to change them. */
406function syncSettingsText(settings: DispatchSettings): string {
407 const side = (executor: 'claude' | 'codex') => {
408 const own = settings.sync?.[executor]
409 return own?.model || own?.effort ? [own.model, own.effort].filter(Boolean).join(' · ') : '同一般派工'
410 }
411 const session = settings.sync?.session ?? '自動'
412 return [
413 `同步模型:claude ${side('claude')}|codex ${side('codex')}|session ${session}`,
414 '設定:/console sync-model [claude|codex] <模型>,/console sync-effort [claude|codex] <強度>(不寫執行者=目前的全域執行者)',
415 '/console sync-session fresh|resume(自動=Claude 有同步模型時開新 session,Codex 接續原本的 session)',
416 '模型名稱照你的帳號可用的填(例如 haiku、luna);用 "" 清除,回到一般派工的模型。同步模型啟動失敗或沒寫回時,會提醒並改回原本的模型。',
417 ].join('\n')
418}
419
420async function saveDispatch($: any, config: ConsoleConfig, field: GlobalField, value?: string): Promise<DispatchSettings> {
421 const { settings, models } = await readDispatch($, config)
422 const options = field === 'executor' ? ['claude', 'codex'] : field === 'model' ? models.map(m => m.model) : effortOptions(settings.executor, models, settings.model)
423 if (field === 'model' && value === undefined && !models.length) throw new Error('模型快取無法讀取;請用 /console model <name> 或 /console effort <level> 設定。')
424 const next = value === undefined ? nextOption(settings[field], options) : (/[\r\n\x00]/.test(value) ? null : value.trim())
425 if (next === null) throw new Error('設定值必須是單行文字。')
426 if (field === 'executor' && next !== 'claude' && next !== 'codex') throw new Error('executor 可選:claude, codex')
427 if (field === 'effort' && next && !options.includes(next)) throw new Error(`effort 可選:${options.join(', ')}`)
428 const updated = { ...settings, [field]: next } as DispatchSettings
429 if (field === 'executor' && next !== settings.executor) { updated.model = ''; updated.effort = '' }
430 // An explicit model change must not carry an unsupported effort into the next dispatch.
431 if (field === 'model' && updated.effort && !effortOptions(updated.executor, models, updated.model).includes(updated.effort)) updated.effort = ''
432 await $.fs.write(config.dispatchSettingsPath, JSON.stringify(updated, null, 2) + '\n')
433 return updated
434}
435
436async function slowProbes($: any, config: ConsoleConfig, needCodex: boolean, home: string) {
437 let probeCodex = ''
438 let quotaText = ''
439 let probeAgents: Agent[] = []
440 let resolved: CompanionResolution | null = null
441 if (needCodex) {
442 const windows = isWindowsOs(await $.env.get('OS'))
443 const preflight = async (script: string) => {
444 const ps = await $.process
445 .run(preflightArgs($.plugin.root, windows, script, config.companionStateDir, config.companionStateRoots), { timeoutMs: 30_000 })
446 .catch(() => null)
447 return ps ? (ps.stdout.trim().split('\n').pop() ?? '').trim() || 'unknown' : 'unknown'
448 }
449 // An empty companionScript is resolved from the installed Codex plugin before the preflight;
450 // a configured one is trusted until the preflight reports it MISSING (a stale version folder).
451 resolved = await resolveCompanion({ read: (path: string) => $.fs.read(path), list: (path: string) => $.fs.list(path) }, home, config.companionScript).catch(() => null)
452 probeCodex = await preflight(resolved?.path || config.companionScript)
453 if (config.companionScript && /\bcompanion=MISSING\b/.test(probeCodex)) {
454 const retry = await resolveCompanion({ read: (path: string) => $.fs.read(path), list: (path: string) => $.fs.list(path) }, home, config.companionScript, true).catch(() => null)
455 if (retry) resolved = retry
456 if (retry && retry.source !== 'configured') probeCodex = await preflight(retry.path)
457 }
458 const q = await $.process
459 .run(quotaArgs($.plugin.root, windows), { timeoutMs: 30_000 })
460 .catch(() => null)
461 quotaText = q ? q.stdout : ''
462 }
463 const ag = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 30_000 }).catch(() => null)
464 try { probeAgents = ag ? JSON.parse(ag.stdout) : [] } catch { probeAgents = [] }
465 return { codex: probeCodex, quotaText, agents: probeAgents, companion: resolved }
466}
467
468async function refresh($: any, options: PluginOptions, force = false) {
469 const generation = dataGeneration
470 if (await demoEnabled($)) { await refreshDemo($, generation); return }
471 if (generation !== dataGeneration) return
472 if (refreshOwner?.generation === generation) { if (force) refreshOwner.queued = true; return }
473 const owner = { generation, queued: false }
474 refreshOwner = owner
475 const current = () => generation === dataGeneration && !demoActive
476 const guarded = async <T,>(work: () => Promise<T>): Promise<T> => {
477 if (!current()) throw DISCARDED_REFRESH
478 const result = await work()
479 if (!current()) throw DISCARDED_REFRESH
480 return result
481 }
482 // Checks surround every external await, so a superseded read cannot start the next read/probe/write.
483 const io = {
484 plugin: { root: $.plugin.root },
485 clock: { now: () => guarded(() => $.clock.now()) },
486 env: { get: (name: string) => guarded(() => name === 'USERPROFILE' ? $.env.get('USERPROFILE')
487 : name === 'HOME' ? $.env.get('HOME') : name === 'LOCALAPPDATA' ? $.env.get('LOCALAPPDATA')
488 : name === 'TMPDIR' ? $.env.get('TMPDIR') : name === 'OS' ? $.env.get('OS') : $.env.get('TEMP')) },
489 fs: { read: (path: string) => guarded(() => $.fs.read(path)), list: (path: string) => guarded(() => $.fs.list(path)), write: (path: string, text: string) => guarded(() => $.fs.write(path, text)) },
490 process: { run: (argv: string[], init: any) => guarded(() => $.process.run(argv, init)) },
491 session: { usage: () => guarded(() => $.session.usage()), id: () => guarded(() => $.session.id()) },
492 }
493 const startingGeneration = launchGeneration
494 let refreshingExecutor: string | undefined
495 let refreshConfig: ConsoleConfig | undefined
496 try {
497 const now = await io.clock.now() as number
498 // Jobs that finished before this plugin lifetime (a reload starts a new one) do not replay their warnings.
499 if (!lifetimeStart) lifetimeStart = now
500 const { home, config } = await paths(io, options)
501 const { settings } = await readDispatch(io, config)
502 refreshingExecutor = executorSignature(settings)
503 refreshConfig = config
504 let error: string | null = null
505 const registry = await io.fs.read(config.registryPath).catch(() => null) as string | null
506 if (registry === null) error = `找不到登錄表 ${config.registryPath};依 README 建立(範例 workflow/projects-scope.example.md),或 /console demo 先看示範`
507 const registryRows = parseRegistry(registry ?? '', home).map(row => ({ row, root: projectRoot(row.statusPath) }))
508 const effective = registryRows.map(({ row, root }) => effectiveDispatch(settings, root, row.executor))
509 const codexInUse = settings.executor === 'codex' || effective.some(item => item.executor === 'codex')
510 if (force || now - lastSlow > SLOW_MS) {
511 const probes = await guarded(() => slowProbes(io, config, codexInUse, home))
512 codex = probes.codex
513 codexQuotaText = probes.quotaText
514 agents = probes.agents
515 if (probes.companion) companion = probes.companion
516 lastSlow = now
517 }
518 const run: ExecutorDeps['run'] = (argv, init) => guarded(() => $.process.run(argv, init))
519 const deps: ExecutorDeps = {
520 run,
521 files: {
522 read: path => guarded(() => $.fs.read(path)), list: path => guarded(() => $.fs.list(path)), write: (path, text) => guarded(() => $.fs.write(path, text)),
523 stat: path => guarded(() => $.fs.stat(path)),
524 },
525 now: () => guarded(() => $.clock.now()),
526 agents: sharedAgents(run),
527 }
528 stateFormatIssues.clear()
529 const gitMode = gitProbeMode((options as any).gitProbe)
530 // What a developer would otherwise type: the recent commits (when HEAD moved), the size of the
531 // uncommitted change (when there is one) and when the last fetch ran. A failure leaves the field out.
532 const gitDetails = async (root: string, git: GitInfo): Promise<GitInfo> => {
533 const quiet = (error: unknown) => { if (error === DISCARDED_REFRESH) throw error; return null }
534 const cached = git.head ? commitCache.get(root) : undefined
535 const [commits, lines, fetched] = await Promise.all([
536 !git.head ? null : cached && cached.head === git.head ? cached.commits
537 : io.process.run(gitLogArgs(root), { timeoutMs: 10_000 }).then((r: any) => {
538 if (r.exitCode !== 0) return null
539 const list = parseGitLog(String(r.stdout ?? ''))
540 commitCache.set(root, { head: git.head!, commits: list })
541 return list
542 }).catch(quiet),
543 git.changed && git.head ? io.process.run(gitNumstatArgs(root), { timeoutMs: 10_000 }).then((r: any) => r.exitCode === 0 ? parseNumstat(String(r.stdout ?? '')) : null).catch(quiet) : null,
544 guarded(() => Promise.resolve().then(() => $.fs.stat(`${root.replace(/[\\/]+$/, '')}/.git/FETCH_HEAD`))).catch(quiet) as Promise<{ mtimeMs?: number } | null>,
545 ])
546 return {
547 ...git,
548 ...(commits && commits.length ? { commits } : {}),
549 ...(lines ? { lines } : {}),
550 ...(typeof fetched?.mtimeMs === 'number' ? { fetchedAt: fetched.mtimeMs } : {}),
551 }
552 }
553 const pullRequest = async (root: string, branch: string, at: number, forced: boolean): Promise<PrInfo | null> => {
554 const cached = prCache.get(root)
555 const live = !!cached?.pr && cached.pr.state === 'OPEN' && cached.pr.checks.pending > 0
556 if (cached && cached.branch === branch && !forced && at - cached.at < (live ? TICK_MS - 5_000 : SLOW_MS)) return cached.pr
557 // gh missing or timed out: keep the last answer and ask again on the slow interval.
558 const r: any = await io.process.run(prViewArgs(), { cwd: root, timeoutMs: 20_000 })
559 .catch((error: unknown) => { if (error === DISCARDED_REFRESH) throw error; return undefined })
560 const pr = r === undefined ? (cached?.branch === branch ? cached.pr : null) : r.exitCode === 0 ? parsePrView(String(r.stdout ?? '')) : null
561 prCache.set(root, { at, branch, pr })
562 return pr
563 }
564 const bases = registryRows.map(({ root }) => root.replace(/\/+$/, '').split('/').pop() ?? '')
565 const kinds = await guarded(() => loadDispatchKinds($))
566 // Projects are independent: read them a few at a time instead of one after another.
567 const loaded = await mapLimit(registryRows, REFRESH_CONCURRENCY, async ({ row, root }, index) => {
568 const eff = effective[index]!
569 const listing: ExecutorKind = eff.executor === 'manual' ? settings.executor : eff.executor
570 const [card, listed, status, stat] = await Promise.all([
571 io.fs.read(row.statusPath).catch(() => null) as Promise<string | null>,
572 guarded(() => workspaceJobs(listing, deps, withCompanion(config), root)),
573 gitMode === 'off' ? null : io.process.run(gitStatusArgs(root), { timeoutMs: 10_000 })
574 .then((r: any) => r.exitCode === 0 ? parseGitStatus(String(r.stdout ?? '')) : null)
575 .catch((error: unknown) => { if (error === DISCARDED_REFRESH) throw error; return null }),
576 guarded(() => Promise.resolve().then(() => $.fs.stat(row.statusPath))).catch((error: unknown) => { if (error === DISCARDED_REFRESH) throw error; return null }) as Promise<{ mtimeMs?: number } | null>,
577 ])
578 const jobs = listed.map(job => {
579 const kind = job.kind ?? kinds.get(jobKey(job))
580 return kind && kind !== job.kind ? { ...job, kind } : job
581 })
582 const git = status ? await gitDetails(root, status) : null
583 const pr = git && gitMode === 'on' && git.branch ? await pullRequest(root, git.branch, now, force) : null
584 const warnings = jobWarnings(row.name, jobs, lifetimeStart)
585 const project: Project = {
586 ...buildProject(row, card, jobs, now, typeof stat?.mtimeMs === 'number' ? stat.mtimeMs : undefined), executor: eff.executor, executorSource: eff.source,
587 ...(row.executor ? { registryExecutor: row.executor } : {}),
588 ...(git ? { git } : {}), ...(pr ? { pr } : {}),
589 }
590 await Promise.all(project.jobs.filter(j => j.kind === 'running').map(async j => {
591 const job = jobs.find(item => item.id === j.id && item.executor === j.executor)
592 if (!job) return
593 const last = await createExecutor(job.executor ?? listing, deps, withCompanion(config)).lastLine(job).catch(() => '')
594 // A resume copy speaks from the old session's memory; its words are not this dispatch's result.
595 j.last = last && job.unmanagedSessionId ? `(resume 複本)${last}` : last
596 }))
597 return { project, warnings }
598 })
599 const projects = loaded.map(item => item.project)
600 const warnings = loaded.flatMap(item => item.warnings)
601 const usage: any = await io.session.usage().catch(() => null)
602 const formatWarning = stateFormatWarning()
603 const companionWarning = [companion?.warning, formatWarning].filter(Boolean).join(';')
604 const selfId = await io.session.id().catch(() => null) as string | null
605 const projectRoots = registryRows.map(({ row, root }) => ({ root, name: row.name }))
606 const cur: Snapshot = {
607 at: now, executor: settings.executor, projects, blocked: relevantBlocked(agents, selfId, projectRoots, home),
608 waiting: waitingNames(agents, selfId, projectRoots, home), codex: codexInUse ? relevantCodex(codex, bases) : '',
609 ...(codexInUse ? { codexInUse: true } : {}),
610 ...(codexInUse && (companion || formatWarning) ? { companion: { path: companion?.path ?? '', source: companion?.source ?? 'none', ...(companionWarning ? { warning: companionWarning } : {}) } } : {}),
611 contextPercent: usage?.context?.percent ?? null, error,
612 ...(typeof usage?.cost?.usd === 'number' ? { costUsd: usage.cost.usd } : {}),
613 codexQuota: parseCodexQuota(codexQuotaText, now),
614 limits: (usage?.rateLimits ?? []).map((l: any) => ({ kind: String(l.kind), percent: Number(l.percentUsed) || 0, ...(l.resetsAt ? { resetsAt: String(l.resetsAt) } : {}) })),
615 }
616 if (executorSignature((await readDispatch(io, config)).settings) !== refreshingExecutor) { owner.queued = true; return }
617 await publishDisplay(async () => {
618 if (!current()) return
619 const prev = await guarded(() => read($, snapshot))
620 const previousFeed = await guarded(() => read($, feedAtom))
621 const sameExecutor = !prev?.demo && prev?.executor === cur.executor ? prev : null
622 const newWarnings = warnings.filter(text => !previousFeed.some(event => event.text === text))
623 const fresh = [...newWarnings.map(text => ({ at: now, text, tone: 'red' as const })), ...events(sameExecutor, cur)]
624 if (fresh.length) await guarded(() => update($, feedAtom, list => current() ? [...fresh, ...list].slice(0, 20) : list))
625 await guarded(() => update($, snapshot, latest => {
626 if (!current()) return latest
627 if (startingGeneration === launchGeneration || !latest) return trackRowChanges(latest, cur, now)
628 // A refresh already in flight must not erase a dispatch accepted after it started.
629 return trackRowChanges(latest, { ...cur, projects: cur.projects.map(project => {
630 const recent = latest.projects.find(item => item.statusPath === project.statusPath)
631 const missing = recent?.jobs.filter(job => acceptedLaunches.has(job.id) && !project.jobs.some(item => item.id === job.id && item.executor === job.executor)) ?? []
632 const ids = new Set(missing.map(job => job.id))
633 return missing.length ? { ...project, jobs: [...missing, ...project.jobs], tasks: [...(recent?.tasks ?? []).filter(task => ids.has(task.id)), ...(project.tasks ?? [])] } : project
634 }) }, now)
635 }))
636 for (const text of [...newWarnings, ...diffToasts(sameExecutor, cur)]) {
637 if (!current()) return
638 $.ui.toast(text, { timeoutMs: 8000 })
639 }
640 })
641 if (current()) {
642 await sweepPending($)
643 await advanceSyncs($, options)
644 await autoSync($, options)
645 await suggestNext($, options)
646 await followGitDetail($).catch(() => {})
647 // Only the open cards of an open pane: a transcript that has not changed is not read again.
648 if (await read($, isPaneOpen)) void syncDigests($, options)
649 }
650 } catch (error) {
651 if (!current() || error === DISCARDED_REFRESH) return
652 if (refreshConfig && executorSignature((await readDispatch(io, refreshConfig)).settings) !== refreshingExecutor) { owner.queued = true; return }
653 if (!current()) return
654 const message = `執行者狀態讀取失敗:${error instanceof Error ? error.message : String(error)}`
655 await publishDisplay(async () => {
656 if (!current()) return
657 await update($, snapshot, previous => !current() ? previous : previous ? { ...previous, error: message } : { at: 0, projects: [], blocked: [], codex: '', contextPercent: null, error: message })
658 if (current()) $.ui.toast(message, { timeoutMs: 8000 })
659 })
660 } finally {
661 if (refreshOwner === owner) {
662 refreshOwner = null
663 if (owner.queued && current()) void refresh($, options, true)
664 }
665 }
666}
667
668async function openPane($: any) {
669 await $.ui.open({ id: PANE, title: '主控台' })
670 await update($, isPaneOpen, () => true)
671}
672
673async function actionNotice($: any, text: string, ok: boolean) {
674 const generation = dataGeneration
675 if (await demoEnabled($)) return
676 const at = await $.clock.now()
677 await publishDisplay(async () => {
678 if (generation !== dataGeneration || await demoEnabled($)) return
679 await update($, feedAtom, list => [{ at, text, tone: ok ? 'green' : 'red' } as const, ...list].slice(0, 20))
680 if (generation === dataGeneration && !demoActive) $.ui.toast(text, { timeoutMs: 8000 })
681 })
682}
683
684/** All entry points share this lock and re-check the latest state, including stale rendered buttons. */
685type ModeChoice = 'auto' | 'console' | 'project'
686async function projectModeOn($: any, options: PluginOptions): Promise<boolean> {
687 const choice = await read($, modeOverride) as ModeChoice
688 if (choice === 'console') return false
689 if (choice === 'project') return true
690 return String((options as any).projectMode ?? 'auto') !== 'off'
691}
692
693/** Project mode's project and its pipeline, or null (mode off, demo, no match). */
694async function focused($: any, options: PluginOptions, s: Snapshot | null): Promise<{ project: Project; pipeline: Pipeline } | null> {
695 if (!s || s.demo || !(await projectModeOn($, options))) return null
696 const project = projectForCwd(s.projects, sessionCwd, projectRoot)
697 if (!project) return null
698 return { project, pipeline: pipeline(project, (await read($, verificationResults))[project.statusPath]) }
699}
700
701async function loadTrust($: any): Promise<Record<string, string>> {
702 const value = await Promise.resolve().then(() => $.store.get(TRUST_KEY)).catch(() => undefined)
703 if (value === undefined) return read($, trustedVerify)
704 const trusted = value && typeof value === 'object' && !Array.isArray(value)
705 ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) : {}
706 await update($, trustedVerify, () => trusted)
707 return trusted
708}
709
710/** First press arms a confirmation for `signature`; returns true only for a matching press inside the window. */
711async function confirmed($: any, statusPath: string, signature: string, now: number, windowMs: number, message: string): Promise<boolean> {
712 const confirmations = await read($, continueConfirmations)
713 if (confirmationMatches(confirmations[statusPath], signature, now, windowMs)) return true
714 await update($, continueConfirmations, values => ({ ...values, [statusPath]: { at: now, signature } }))
715 $.ui.toast(message, { timeoutMs: windowMs })
716 $.clock.after(windowMs, () => void update($, continueConfirmations, values => {
717 if (values[statusPath]?.at !== now) return values
718 const next = { ...values }; delete next[statusPath]; return next
719 }))
720 return false
721}
722
723/**
724 * `text`: a reply's words, sent to the executor that asked; without them a reply fills the prompt box instead.
725 * `confirmed`: the person already confirmed a continue (they sent the `/console continue` command), so no second press.
726 */
727async function triggerAction($: any, options: PluginOptions, statusPath: string, kind: ActionKind, request: { useClaude?: boolean; auto?: boolean; text?: string; confirmed?: boolean } = {}) {
728 if (await demoEnabled($)) {
729 $.ui.toast('示範資料:不執行專案操作;/console refresh 回到實際資料。')
730 return
731 }
732 if (actionLocks.has(statusPath)) return
733 const token = ++lockSeq
734 actionLocks.set(statusPath, { kind, token })
735 let timer: { cancel(): void } | undefined
736 let project: Project | undefined
737 let ownsPending = false
738 let keepPending = false
739 try {
740 // A pending action the row still shows but nothing will finish (a review the CARD moved past or that
741 // timed out, an action left by an earlier plugin lifetime) is dropped here, so the press goes ahead.
742 if ((await read($, pendingActions))[statusPath] && !(await sweepPending($, { only: statusPath, ownToken: token })).length) return
743 const current = await read($, snapshot)
744 project = current?.projects.find(p => p.statusPath === statusPath)
745 if (project && (kind === 'sync' || kind === 'continue' || kind === 'reply') && dispatchBlockReason(project)) throw new Error(dispatchBlockReason(project))
746 const state = current && project ? rows(current).find(r => r.full === project?.name)?.state : undefined
747 if (!project || !state || !actionKinds(project, state).includes(kind)) {
748 await actionNotice($, '狀態已變更,請依面板目前的動作操作。', false)
749 return
750 }
751 const p = project
752 if ((kind === 'sync' || kind === 'continue' || kind === 'reply') && current?.error) throw new Error('請先成功更新執行者狀態,再派工。')
753 const name = p.name.replace(/\s.*$/, '')
754 const now = await $.clock.now()
755 if (kind === 'continue' && !request.useClaude && !request.confirmed) {
756 if (!await confirmed($, statusPath, workSignature(p), now, CONTINUE_CONFIRM_MS,
757 `${name}:${CONTINUE_CONFIRM_MS / 1000} 秒內再按一次派工:${p.next}`)) return
758 }
759 if (kind === 'verify') {
760 const trusted = await loadTrust($)
761 if (!verifyTrusted(trusted, statusPath, p.verify)) {
762 const why = trusted[statusPath] === undefined ? '首次執行此驗證指令' : '驗證指令已變更'
763 if (!await confirmed($, statusPath, verifySignature(p.verify), now, VERIFY_CONFIRM_MS,
764 `${name}:${why},確認後 ${VERIFY_CONFIRM_MS / 1000} 秒內再按一次執行:${p.verify}`)) return
765 const approved = { ...trusted, [statusPath]: p.verify }
766 await update($, trustedVerify, () => approved)
767 // Not remembered across sessions when the store refuses; the confirmed run still goes ahead.
768 await Promise.resolve().then(() => $.store.set(TRUST_KEY, approved)).catch(() => {})
769 }
770 }
771 await update($, continueConfirmations, values => { const next = { ...values }; delete next[statusPath]; return next })
772 $.ui.toast(`${name}:${actionLabel(kind, p)}…`, { timeoutMs: 3000 })
773 await update($, pendingActions, values => ({ ...values, [statusPath]: { kind, at: now } }))
774 ownsPending = true
775 if (kind === 'sync') await setSync($, statusPath, { stage: 'dispatch', at: now, cardAt: cardStamp(p), ...(request.auto ? { auto: true } : {}) })
776 // Once a second: the label shows elapsed seconds; terminal/desktop animate a client spinner beside it,
777 // so the whole pane is not redrawn several times a second (or sent to a phone that often).
778 timer = $.clock.every(1000, async () => {
779 if (!(await read($, pendingActions))[statusPath]) { timer?.cancel(); return }
780 await update($, actionPulse, value => value + 1)
781 })
782 const { home, config } = await paths($, options)
783 const root = projectRoot(p.statusPath)
784 if (kind === 'verify') {
785 let result: VerificationResult
786 try {
787 const windows = /^[a-z]:\//i.test(root) || root.startsWith('//') || (await $.env.get('OS')) === 'Windows_NT'
788 const run = await $.process.run(verificationArgs(p.verify, windows), { cwd: root, timeoutMs: 300_000 })
789 result = verificationResult(p.verify, await $.clock.now(), run)
790 } catch (error) {
791 result = { command: p.verify, at: await $.clock.now(), ok: false, exitCode: null, lines: outputTail(String(error)), truncated: false }
792 }
793 await update($, verificationResults, values => ({ ...values, [statusPath]: result }))
794 await actionNotice($, `${name}:${result.ok ? '✓ 驗證通過' : '✕ 驗證失敗'}${result.exitCode === null ? '(逾時或無法執行)' : `(exit ${result.exitCode})`}`, result.ok)
795 } else if (kind === 'reply' && !request.text?.trim()) {
796 // The answer is typed in the prompt box: a command that carries it back here, to the session that asked.
797 const filled = await $.prompt.fill({ text: `/console reply ${p.name} `, mode: 'replace' })
798 if (!filled.isFilled) throw new Error(filled.refusal === 'no_composer' ? `此介面沒有可預填的輸入框;請輸入 /console reply ${p.name} <回覆>。` : '輸入框目前無法預填,請關閉對話框後重試。')
799 await $.ui.close({ id: PANE }).catch(() => {})
800 await update($, isPaneOpen, () => false)
801 await actionNotice($, `${name}:在輸入框寫下回覆,Enter 送給執行者`, true)
802 } else if (kind === 'sync' || kind === 'continue' || kind === 'reply') {
803 const dispatchKindOf: 'sync' | 'continue' = kind === 'sync' ? 'sync' : 'continue'
804 const { settings } = await readDispatch($, config)
805 const eff = effectiveDispatch(settings, root, p.registryExecutor)
806 if ((p.executor ?? current?.executor) && (p.executor ?? current?.executor) !== eff.executor) throw new Error('執行者已變更,請更新面板後再操作。')
807 if (eff.executor === 'manual') throw new Error('此專案設為 manual:面板不派工,請手動交接。')
808 const deps: ExecutorDeps = {
809 run: (argv, init) => $.process.run(argv, init),
810 files: { read: path => $.fs.read(path), list: path => $.fs.list(path), write: (path, text) => $.fs.write(path, text) },
811 now: () => $.clock.now(),
812 }
813 if (eff.executor === 'codex' && !companion?.path && !config.companionScript) companion = await resolveCompanion({ read: (path: string) => $.fs.read(path), list: (path: string) => $.fs.list(path) }, home, config.companionScript).catch(() => null)
814 const execConfig = withCompanion(config)
815 // Claude stand-in for Codex uses the global model/effort only when they are Claude's own.
816 const claudeOpts = { model: settings.executor === 'claude' ? settings.model : '', effort: settings.executor === 'claude' ? settings.effort : '' }
817 let chosen: ExecutorKind = eff.executor
818 let dispatchOpts: DispatchOptions = { model: eff.model, effort: eff.effort, kind: dispatchKindOf }
819 const offer = (await read($, fallbackOffers))[statusPath]
820 if (kind === 'reply') {
821 // Only a Claude session stops to ask, so the reply resumes it with Claude whatever the project uses now.
822 if (eff.executor !== 'claude') { chosen = 'claude'; dispatchOpts = { ...claudeOpts, kind: dispatchKindOf } }
823 } else if (eff.executor === 'codex' && request.useClaude) {
824 chosen = 'claude'
825 dispatchOpts = { ...claudeOpts, kind: dispatchKindOf, fallbackFrom: 'codex', fallbackReason: offer?.reason ?? '使用者選擇改用 Claude' }
826 } else if (eff.executor === 'codex') {
827 const fallback = resolveFallbackOptions(options)
828 const base = root.replace(/\/+$/, '').split('/').pop() ?? ''
829 const decision = decideCodexDispatch({
830 mode: fallback.codexFallback, minPercent: fallback.codexMinQuotaPercent, quota: current?.codexQuota,
831 preflight: relevantCodex(codex, [base]), companionPath: execConfig.companionScript, now,
832 })
833 if (decision.action === 'ask') {
834 await update($, fallbackOffers, values => ({ ...values, [statusPath]: { kind, reason: decision.reason, at: now } }))
835 throw new Error(`Codex 未派工:${decision.reason}。可按「改用 Claude 派工」。`)
836 }
837 if (decision.action === 'claude') {
838 chosen = 'claude'
839 dispatchOpts = { ...claudeOpts, kind: dispatchKindOf, fallbackFrom: 'codex', fallbackReason: decision.reason }
840 }
841 }
842 const jobs = await workspaceJobs(chosen, deps, execConfig, root)
843 const protection = buildProject({ name: p.name, statusPath }, null, jobs, now)
844 const reason = dispatchBlockReason(protection)
845 if (reason) {
846 await refresh($, options, true)
847 throw new Error(reason)
848 }
849 // An independent review runs in a new session (Claude) or a new thread (Codex `--fresh`), never in the work's.
850 const review = kind === 'continue' && freshSession(p)
851 if (review) dispatchOpts = { ...dispatchOpts, fresh: true }
852 // A sync of a row whose job stopped to ask resumes that job's session, which may be a review's rather than the project's.
853 // A reply goes to that same session, with the person's words as the prompt.
854 const asked = (kind === 'sync' || kind === 'reply') && chosen === 'claude' ? askingSession(p) : undefined
855 if (kind === 'reply' && !asked) throw new Error('找不到等你回覆的執行者 session;請更新面板。')
856 if (asked) dispatchOpts = { ...dispatchOpts, resumeSession: asked }
857 // A sync may have its own (cheaper) model and run in a new small session; a row that asked keeps its session.
858 // A sync model that failed before is skipped, and one that will not launch falls back to the normal model now.
859 let syncPlan: { model: string; modelKey: string; fresh: boolean } | null = null
860 let normalOpts: DispatchOptions | null = null
861 if (kind === 'sync' && !asked) {
862 const plan = syncDispatch(settings, root, chosen, dispatchOpts)
863 const modelKey = `${chosen}:${plan.model}|${plan.effort}`
864 const failed = plan.custom ? (await failedSyncModels($)).get(modelKey) : undefined
865 if (plan.custom && !failed) { normalOpts = dispatchOpts; dispatchOpts = { ...dispatchOpts, model: plan.model, effort: plan.effort } }
866 if (plan.fresh) dispatchOpts = { ...dispatchOpts, fresh: true }
867 syncPlan = { model: plan.custom && !failed ? plan.model || plan.effort : '', modelKey: plan.custom && !failed ? modelKey : '', fresh: plan.fresh }
868 if (failed) $.ui.toast(`${name}:同步模型 ${syncModelLabel(modelKey)} 上次${failed},這次改用原本的模型。改好設定後用 /console sync-model 重新指定。`, { timeoutMs: 8000 })
869 }
870 const executor = createExecutor(chosen, deps, execConfig)
871 // A continue carries what the executor was last doing, so a fresh session knows where it stopped; so does a fresh sync.
872 // A review does not: it judges the work from the files, not from the worker's account of it.
873 const prompt = kind === 'reply' ? replyPrompt(p, request.text ?? '')
874 : review ? reviewPrompt(p)
875 : kind === 'continue' ? `${dispatchPrompt(p, kind)}\n${await digestBlock($, options, p)}`
876 : syncPlan?.fresh ? freshSyncPrompt(p, await digestBlock($, options, p)) : dispatchPrompt(p, dispatchKindOf)
877 let job: ExecutorJob
878 try {
879 job = await executor.dispatch(root, prompt, dispatchOpts)
880 } catch (error) {
881 if (!normalOpts || !syncPlan) throw error
882 // The account may not have this model: say so, remember it, and sync with the normal model instead.
883 const why = error instanceof Error ? error.message : String(error)
884 await markSyncModelFailed($, syncPlan.modelKey, '無法啟動')
885 $.ui.toast(`${name}:同步模型 ${syncPlan.model} 無法啟動(${why}),改用原本的模型同步;之後的同步也先用原本的模型。`, { timeoutMs: 8000 })
886 dispatchOpts = { ...normalOpts, ...(dispatchOpts.fresh ? { fresh: true } : {}) }
887 syncPlan = { ...syncPlan, model: '', modelKey: '' }
888 job = await executor.dispatch(root, prompt, dispatchOpts)
889 }
890 await update($, fallbackOffers, values => { if (!values[statusPath]) return values; const next = { ...values }; delete next[statusPath]; return next })
891 if (isActiveJob(job)) unpublishedLaunches.set(`${workspaceKey(root)}:${jobKey(job)}`, { root: workspaceKey(root), job })
892 const acceptedAt = await $.clock.now()
893 const id = job.id
894 const startedAt = new Date(acceptedAt).toISOString()
895 acceptedLaunches.add(id)
896 await rememberDispatchKind($, jobKey({ executor: chosen, id }), dispatchKindOf)
897 launchGeneration++
898 await update($, snapshot, value => value ? trackRowChanges(value, { ...value, projects: value.projects.map(item => item.statusPath === statusPath ? {
899 ...item,
900 jobs: [{ kind: 'running' as const, id, executor: chosen, status: 'queued', summary: actionLabel(kind, p), startedAt, phase: '等待任務狀態' }, ...item.jobs.filter(j => j.id !== id || j.executor !== chosen)],
901 tasks: [{ id, executor: chosen, ...(dispatchOpts.fallbackFrom ? { fallbackFrom: dispatchOpts.fallbackFrom } : {}), status: 'queued', title: actionLabel(kind, p), model: dispatchOpts.model ?? '', effort: dispatchOpts.effort ?? '', startedAt }, ...(item.tasks ?? []).filter(t => t.id !== id)],
902 } : item) }, acceptedAt) : value)
903 if (kind === 'sync') {
904 await update($, syncProgress, values => values[statusPath]?.stage !== 'dispatch' ? values
905 : { ...values, [statusPath]: {
906 ...values[statusPath]!, stage: 'running', executor: chosen, jobId: id, phase: 'queued',
907 ...(syncPlan?.model ? { model: syncPlan.model, modelKey: syncPlan.modelKey } : {}),
908 ...(syncPlan?.fresh ? { fresh: true } : {}),
909 } })
910 void scheduleFastTick($, options)
911 }
912 const fallbackNote = (dispatchOpts.fallbackFrom ? `(Codex 改由 Claude:${dispatchOpts.fallbackReason})` : '')
913 + (syncPlan?.model || syncPlan?.fresh ? `(${[syncPlan.model && `用 ${syncPlan.model}`, syncPlan.fresh && '新 session'].filter(Boolean).join('・')})` : '')
914 await actionNotice($, `${name}:${chosen} 已接受${kind === 'sync' ? '同步 STATUS' : kind === 'reply' ? '你的回覆' : '繼續下一步'}${fallbackNote},等待執行結果`, true)
915 } else if (kind === 'decide') {
916 // Options already picked in the pane go into the draft, so the CTA and `d` never drop them.
917 const picked = (await read($, decisionPicks))[p.statusPath + '\n' + p.ask] ?? {}
918 const answer = Object.keys(picked).length ? decisionAnswer(parseAsk(p.ask), picked) : ''
919 const filled = await $.prompt.fill({ text: `「${p.name}」決策:${answer}`, mode: 'replace' })
920 if (!filled.isFilled) throw new Error(filled.refusal === 'no_composer' ? '此介面沒有可預填的輸入框;請在主控台輸入決策。' : '輸入框目前無法預填,請關閉對話框後重試。')
921 await update($, selected, () => p.name)
922 // Removing the focused pane returns keyboard input to the filled composer.
923 await $.ui.close({ id: PANE }).catch(() => {})
924 await update($, isPaneOpen, () => false)
925 await actionNotice($, answer && !answer.includes(':') ? `${name}:決策已填入輸入框,Enter 送出` : `${name}:決策草稿已預填,請補完後送出`, true)
926 } else if (kind === 'gate') {
927 const context = selectionContext(current, p.name)
928 // PromptSubmitArgs has no context field, and calls from this plugin skip its own hook.
929 // Carry the same selection block in the submitted text without consuming a user's draft selection.
930 const text = `${gatePrompt(p)}\n\n${context ?? ''}\n${await digestBlock($, options, p)}`
931 await update($, reviewRequests, values => ({ ...values, [statusPath]: { text, projectName: p.name, at: now, gate: p.gate ?? '' } }))
932 try {
933 const result = await $.prompt.submit({ text })
934 if (result.drop) throw new Error(result.drop)
935 let turnId: string | undefined
936 for (const [id, startedText] of earlyReviewStarts) {
937 if (reviewTurnMatches({ text: result.text }, startedText)) { turnId = id; break }
938 }
939 await update($, reviewRequests, values => {
940 const request = values[statusPath]
941 if (!request) return values
942 turnId = request.turnId ?? turnId
943 return { ...values, [statusPath]: { ...request, text: result.text, ...(turnId ? { turnId } : {}) } }
944 })
945 if (turnId) earlyReviewStarts.delete(turnId)
946 keepPending = true
947 } catch (error) {
948 await update($, reviewRequests, values => { const next = { ...values }; delete next[statusPath]; return next })
949 throw error
950 }
951 await actionNotice($, `${name}:關卡已送交主控台審核${parseGate(p.gate)?.kind === 'release' ? ',正式執行仍待你決定' : ''}`, true)
952 } else {
953 const opened = await $.process.run(['code', p.statusPath], { timeoutMs: 15_000 }).catch(() => null)
954 if (!opened || opened.exitCode !== 0) {
955 const fallback = await $.process.run(openFallbackArgs(p.statusPath, isWindowsOs(await $.env.get('OS'))), { timeoutMs: 15_000 })
956 if (fallback.exitCode !== 0) throw new Error(`無法開啟 STATUS.md(exit ${fallback.exitCode})`)
957 }
958 await actionNotice($, `${name}:已請求開啟 STATUS.md`, true)
959 }
960 } catch (error) {
961 const message = error instanceof Error ? error.message : String(error)
962 if (kind === 'sync') {
963 const endedAt = await $.clock.now()
964 await update($, syncProgress, values => values[statusPath]?.stage !== 'dispatch' ? values
965 : { ...values, [statusPath]: { ...values[statusPath]!, stage: 'dispatch-failed', endedAt, detail: message } })
966 }
967 await actionNotice($, `${project?.name ?? statusPath}:✕ ${message}`, false)
968 } finally {
969 if (!keepPending) timer?.cancel()
970 try {
971 if (ownsPending && !keepPending) await update($, pendingActions, values => { const next = { ...values }; delete next[statusPath]; return next })
972 } finally { if (actionLocks.get(statusPath)?.token === token) actionLocks.delete(statusPath) }
973 }
974}
975
976/**
977 * Forgets one pending action, the one given (same kind and time), and, unless told to keep it, its review request.
978 * Anything pressed on the row since then is left alone. Returns whether the pending action was still there to drop;
979 * only then does a review's lock go too, so the row can act again.
980 */
981async function dropPending($: any, path: string, entry: PendingAction, { request = true, keepToken }: { request?: boolean; keepToken?: number } = {}): Promise<boolean> {
982 let dropped = false
983 await update($, pendingActions, values => {
984 const held = values[path]
985 if (!held || held.kind !== entry.kind || held.at !== entry.at) return values
986 dropped = true
987 const next = { ...values }; delete next[path]; return next
988 })
989 if (request) await update($, reviewRequests, values => { if (!values[path] || (entry.kind === 'gate' && values[path].at !== entry.at)) return values; const next = { ...values }; delete next[path]; return next })
990 const lock = actionLocks.get(path)
991 if (dropped && lock?.kind === 'gate' && lock.token !== keepToken) actionLocks.delete(path)
992 return dropped
993}
994
995/**
996 * Drops pending actions nothing will finish, so a row never stays locked: a review whose gate the CARD moved
997 * past, one that waited GATE_PENDING_MS with no turn running for it, and any action without a lock in this
998 * plugin lifetime. Runs after each refresh, at every main-thread turn end, on `/console off` (which also lets go
999 * of every review not running) and before a press on a row that still shows one. Returns the paths it dropped.
1000 */
1001async function sweepPending($: any, scope: { only?: string; ownToken?: number; offConsole?: boolean } = {}): Promise<string[]> {
1002 const pending = await read($, pendingActions)
1003 const paths = Object.keys(pending).filter(path => !scope.only || path === scope.only)
1004 if (!paths.length) return []
1005 const requests = await read($, reviewRequests)
1006 const s = await read($, snapshot)
1007 const now = await $.clock.now()
1008 const turnRunning = await read($, isTurnRunning)
1009 const dropped: { path: string; entry: PendingAction; name: string; reason: string; keepRequest: boolean }[] = []
1010 for (const path of paths) {
1011 const entry = pending[path]
1012 const request = requests[path]
1013 const project = s?.projects.find(p => p.statusPath === path)
1014 const lock = actionLocks.get(path)
1015 const locked = !!lock && lock.token !== scope.ownToken
1016 let reason = stalePendingReason(entry, request, project, now, locked, turnRunning)
1017 if (!reason && scope.offConsole && entry.kind === 'gate' && !(request?.turnId && turnRunning)) reason = '主控台已關閉,審核狀態已清除'
1018 if (!reason) continue
1019 const name = (project?.name ?? request?.projectName ?? path).replace(/\s.*$/, '')
1020 // Only the row is let go: a review turn already bound, or one still queued behind a long turn when the wait
1021 // ran out, still reports its answer when it ends.
1022 dropped.push({ path, entry, name, reason, keepRequest: (reason === '關卡已變更' && !!request?.turnId) || reason === '審核狀態已逾時' })
1023 }
1024 const done: typeof dropped = []
1025 for (const item of dropped) if (await dropPending($, item.path, item.entry, { request: !item.keepRequest, keepToken: scope.ownToken })) done.push(item)
1026 for (const item of done) {
1027 const text = `${item.name}:${item.reason},按鈕已解鎖`
1028 // The pane is closing on /console off, and the display generation moves on: say it directly.
1029 if (scope.offConsole) $.ui.toast(text, { timeoutMs: 8000 })
1030 else await actionNotice($, text, false)
1031 }
1032 return done.map(item => item.path)
1033}
1034
1035async function setSync($: any, statusPath: string, track: SyncProgress) {
1036 await update($, syncProgress, values => ({ ...values, [statusPath]: track }))
1037}
1038
1039/** After a refresh: move each sync along, say when one ends, and keep the fast tick while anything runs. */
1040async function advanceSyncs($: any, options: PluginOptions) {
1041 const s = await read($, snapshot)
1042 if (!s || s.demo) return
1043 const ended: { name: string; track: SyncProgress }[] = []
1044 await update($, syncProgress, values => {
1045 const next: Record<string, SyncProgress> = {}
1046 for (const [path, track] of Object.entries(values)) {
1047 const project = s.projects.find(p => p.statusPath === path)
1048 const moved = advanceSync(track, project, s.at)
1049 if (!moved) continue
1050 if (project && !isSyncEnded(track.stage) && isSyncEnded(moved.stage)) ended.push({ name: project.name.replace(/\s.*$/, ''), track: moved })
1051 next[path] = moved
1052 }
1053 return next
1054 })
1055 for (const { name, track } of ended) {
1056 await actionNotice($, `${name}:${syncStatus(track, s.at)}`, track.stage === 'done')
1057 // A cheap sync model that failed or wrote nothing is not used again until the setting changes.
1058 if (track.modelKey && (track.stage === 'unchanged' || track.stage === 'run-failed')) {
1059 await markSyncModelFailed($, track.modelKey, track.stage === 'unchanged' ? '沒寫回 STATUS' : '同步失敗')
1060 $.ui.toast(`${name}:用 ${track.model} 同步${track.stage === 'unchanged' ? '沒寫回 STATUS' : '失敗'},之後的同步改回原本的模型(/console sync-model 可重新指定)。`, { timeoutMs: 10_000 })
1061 }
1062 }
1063 await scheduleFastTick($, options)
1064}
1065
1066/** Refresh every 20 s while a job or a sync is under way; back to the minute tick once all are done. */
1067async function scheduleFastTick($: any, options: PluginOptions) {
1068 const s = await read($, snapshot)
1069 const syncing = Object.values(await read($, syncProgress)).some(track => !isSyncEnded(track.stage))
1070 const busy = consoleActive && !s?.demo && (syncing || !!s?.projects.some(p => p.jobs.some(j => j.kind === 'running')))
1071 if (busy && !fastTimer) fastTimer = $.clock.every(FAST_TICK_MS, () => void refresh($, options))
1072 if (!busy && fastTimer) { fastTimer.cancel(); fastTimer = undefined }
1073}
1074
1075/**
1076 * `autoSync: on`: a project in 待同步 whose finished work left the CARD behind gets one sync
1077 * dispatched by itself, once per job. Nothing is retried: a sync that fails or writes nothing waits for a person.
1078 */
1079async function autoSync($: any, options: PluginOptions) {
1080 if (autoSyncMode((options as any).autoSync) === 'off' || !consoleActive || await demoEnabled($)) return
1081 const s = await read($, snapshot)
1082 if (!s || s.demo || s.error) return
1083 const pending = await read($, pendingActions)
1084 const tracks = await read($, syncProgress)
1085 const stored: unknown = await Promise.resolve().then(() => $.store.get(AUTO_SYNCED_KEY)).catch(() => null)
1086 const done = new Set([...(Array.isArray(stored) ? stored.filter((item): item is string => typeof item === 'string') : []), ...autoSynced])
1087 for (const row of rows(s)) {
1088 if (row.state !== 'SYNC') continue
1089 const p = s.projects.find(item => item.name === row.full)
1090 if (!p || pending[p.statusPath] || actionLocks.has(p.statusPath)) continue
1091 const track = tracks[p.statusPath]
1092 if (track && !isSyncEnded(track.stage)) continue
1093 const jobs = autoSyncJobs(p)
1094 if (!jobs.some(job => !done.has(job))) continue
1095 for (const job of jobs) { done.add(job); autoSynced.add(job) }
1096 while (autoSynced.size > AUTO_SYNCED_MAX) autoSynced.delete(autoSynced.values().next().value as string)
1097 const kept = [...done].slice(-AUTO_SYNCED_MAX)
1098 // Remembered before dispatching, so another console session (or the next refresh) does not send it twice.
1099 await Promise.resolve().then(() => $.store.set(AUTO_SYNCED_KEY, kept)).catch(() => {})
1100 await triggerAction($, options, p.statusPath, 'sync', { auto: true })
1101 }
1102}
1103
1104/** The last suggestion the prompt box showed, so a refresh proposes again only when the way on changed. */
1105let lastSuggestion: string | null = null
1106
1107/**
1108 * `suggestNext: on`: the way on (a decision, a reply, a gate, a sync, a continue) as the prompt box's dim
1109 * suggestion, Tab to take. Proposed when it changes; one the box could not show (a turn ran, the person was
1110 * typing) is proposed again at the next refresh.
1111 */
1112async function suggestNext($: any, options: PluginOptions) {
1113 if (suggestNextMode((options as any).suggestNext) === 'off' || !consoleActive || await demoEnabled($)) return
1114 const s = await read($, snapshot)
1115 const text = s ? suggestedPrompt(s, await read($, pendingActions)) : null
1116 if (!text) { lastSuggestion = null; return }
1117 if (text === lastSuggestion) return
1118 const shown = await Promise.resolve().then(() => $.prompt.suggest({ text })).catch(() => null)
1119 if (shown?.isShown) lastSuggestion = text
1120}
1121
1122/** The TTL in force and where it came from: the option, else what usage showed or an idle gap proved (remembered across sessions), else 5m. */
1123async function cacheTtlFor($: any, options: PluginOptions, learned: CacheTtl | null): Promise<{ ttl: CacheTtl; source: TtlSource }> {
1124 const option = cacheTtlOption((options as any).cacheTtl)
1125 if (option !== 'auto') return { ttl: option, source: 'option' }
1126 const stored: any = await Promise.resolve().then(() => $.store.get(CACHE_TTL_KEY)).catch(() => null)
1127 // What usage reported outranks a guess from an idle gap.
1128 if (stored && typeof stored === 'object' && stored.source === 'usage' && (stored.ttl === '5m' || stored.ttl === '1h')) return { ttl: stored.ttl, source: 'usage' }
1129 if (learned) {
1130 await Promise.resolve().then(() => $.store.set(CACHE_TTL_KEY, learned)).catch(() => {})
1131 return { ttl: learned, source: 'learned' }
1132 }
1133 return stored === '1h' || stored === '5m' ? { ttl: stored, source: 'learned' } : { ttl: '5m', source: 'default' }
1134}
1135
1136/** The transcript's tail: whole when it fits a read, else its last lines through the shell. */
1137async function transcriptTail($: any, path: string): Promise<string | null> {
1138 const text = await Promise.resolve().then(() => $.fs.read(path)).catch(() => null)
1139 if (typeof text === 'string') return text
1140 const stat: any = await Promise.resolve().then(() => $.fs.stat(path)).catch(() => null)
1141 if (!stat) return null
1142 const args = isWindowsOs(await $.env.get('OS'))
1143 ? ['powershell', '-NoProfile', '-Command', `Get-Content -LiteralPath '${path.replace(/'/g, "''")}' -Tail 400 -Encoding UTF8`]
1144 : ['tail', '-n', '400', path]
1145 const r: any = await $.process.run(args, { timeoutMs: 10_000 }).catch(() => null)
1146 return r && r.exitCode === 0 ? String(r.stdout ?? '') : null
1147}
1148
1149/**
1150 * Reads the TTL actually in force from the transcript's usage (`cache_creation` by TTL) and remembers it;
1151 * with `seed`, a console that has no clock yet (just installed or reloaded) starts from the last response.
1152 */
1153async function syncCacheFromTranscript($: any, options: PluginOptions, seed: boolean) {
1154 if (!transcriptPath || demoActive) return
1155 const text = await transcriptTail($, transcriptPath)
1156 const found = text ? transcriptCache(text) : null
1157 if (!found) return
1158 const auto = cacheTtlOption((options as any).cacheTtl) === 'auto'
1159 if (found.ttl && auto) await Promise.resolve().then(() => $.store.set(CACHE_TTL_KEY, { ttl: found.ttl, source: 'usage' })).catch(() => {})
1160 const clock = await read($, cacheClock)
1161 if (!clock) {
1162 if (!seed || await read($, isTurnRunning)) return
1163 const { ttl, source } = found.ttl && auto ? { ttl: found.ttl, source: 'usage' as const } : await cacheTtlFor($, options, null)
1164 await update($, cacheClock, value => value ?? ({ at: found.at, tokens: found.tokens, model: found.model, ttl, source, ...(found.hit === undefined ? {} : { hit: found.hit }) }) as CacheClock)
1165 } else if (found.ttl && auto && (clock.ttl !== found.ttl || clock.source !== 'usage')) {
1166 await update($, cacheClock, value => value ? ({ ...value, ttl: found.ttl!, source: 'usage' }) as CacheClock : value)
1167 }
1168}
1169
1170// Loop guard: the second identical failure in a row tells the model not to try a third time.
1171async function loopCheck($: any, e: any, result: any) {
1172 if (!result || result.deny !== undefined) return result
1173 if (!loops.record(loopKey(e), result.isError ? String(result.text ?? '') : null)) return result
1174 $.ui.toast(`重複失敗護欄:${e.tool} 以相同參數第二次失敗,已提醒 Claude 換個做法`, { timeoutMs: 6000 })
1175 return { ...result, context: [...(result.context ?? []), LOOP_NOTE] }
1176}
1177
1178/** Command guard: the answer for a refused command, or null to run it. */
1179async function commandGuard($: any, options: PluginOptions, e: any): Promise<{ deny: string } | null> {
1180 // PowerShell is the Windows shell tool where a build offers it.
1181 const tool: string = e.tool
1182 if (tool !== 'Bash' && tool !== 'PowerShell') return null
1183 const mode = guardMode((options as any).commandGuard)
1184 const command = String((e as any).command ?? '')
1185 const reason = mode === 'off' ? null : dangerReason(command)
1186 if (!reason) return null
1187 const preview = commandPreview(command)
1188 let allowed = false
1189 if (mode === 'ask') {
1190 const answer = await $.ui.ask(`指令護欄:${reason}。\n${preview}\n要執行這個指令嗎?`, { header: '指令護欄', options: ['執行一次', '拒絕'] }).catch(() => '')
1191 allowed = answer === '執行一次'
1192 }
1193 const at = await $.clock.now()
1194 await update($, feedAtom, list => [{ at, text: `指令護欄${allowed ? '放行' : '攔下'}:${reason}`, tone: allowed ? 'amber' : 'red' } as const, ...list].slice(0, 20))
1195 if (allowed) return null
1196 $.ui.toast(`指令護欄攔下:${reason}`, { timeoutMs: 6000 })
1197 return { deny: `console-status 指令護欄攔下這個指令(${reason})${mode === 'deny' ? ':commandGuard 設為 deny' : ':使用者沒有同意'}。不要換個寫法重試同樣的效果;改用可復原的做法,或請使用者自己執行。` }
1198}
1199
1200/** Redraws the countdown and warns once, shortly before the cache goes cold. */hooks/config.ts 73 lines1export type ConsoleConfig = {
2 registryPath: string
3 companionScript: string
4 companionStateDir: string
5 executor: 'claude' | 'codex'
6 dispatchSettingsPath: string
7 claudeSessionsPath: string
8 modelsCachePath: string
9 defaultModel: string
10 defaultEffort: string
11 companionStateRoots: string[]
12}
13
14/** Resolve user paths without shell expansion or a versioned plugin cache path. */
15export function resolveConfig(options: Readonly<Record<string, unknown>>, home: string, local: string, temp = '/tmp'): ConsoleConfig {
16 const value = (key: string, fallback = '') => typeof options[key] === 'string' && (options[key] as string).trim()
17 ? (options[key] as string).trim() : fallback
18 const path = (text: string) => text.replace(/\\/g, '/').replace(/^~(?=\/|$)/, home.replace(/\\/g, '/'))
19 const legacyState = path(value('companionStateDir', local ? `${local}/Temp/codex-companion` : `${temp}/codex-companion`))
20 let roots = [path('~/.claude/plugins/data/codex-openai-codex/state'), legacyState]
21 try {
22 const configured = typeof options.companionStateRoots === 'string' ? JSON.parse(options.companionStateRoots) : options.companionStateRoots
23 if (Array.isArray(configured) && configured.length && configured.every(v => typeof v === 'string' && v.trim())) roots = configured.map(v => path(v.trim()))
24 } catch { /* Invalid list falls back to both standard roots. */ }
25 return {
26 registryPath: path(value('registryPath', '~/.claude/handoffs/projects-scope.md')),
27 companionScript: path(value('companionScript')),
28 companionStateDir: legacyState,
29 executor: value('executor', 'claude') === 'codex' ? 'codex' : 'claude',
30 dispatchSettingsPath: path(value('dispatchSettingsPath', '~/.claude/handoffs/dispatch.json')),
31 claudeSessionsPath: path(value('claudeSessionsPath', '~/.claude/handoffs/claude-sessions.json')),
32 modelsCachePath: path(value('modelsCachePath', '~/.codex/models_cache.json')),
33 defaultModel: typeof options.defaultModel === 'string' ? options.defaultModel.trim() : '',
34 defaultEffort: typeof options.defaultEffort === 'string' ? options.defaultEffort.trim() : '',
35 companionStateRoots: [...new Set(roots)],
36 }
37}
38
39export type FallbackMode = 'ask' | 'claude' | 'off'
40export type FallbackOptions = { codexFallback: FallbackMode; codexMinQuotaPercent: number }
41
42/** Codex quota/broker fallback options; kept apart from resolveConfig so its shape stays stable. */
43export function resolveFallbackOptions(options: Readonly<Record<string, unknown>>): FallbackOptions {
44 const mode = typeof options.codexFallback === 'string' ? options.codexFallback.trim().toLowerCase() : ''
45 const raw = options.codexMinQuotaPercent
46 const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw.trim()) : NaN
47 return {
48 codexFallback: mode === 'claude' || mode === 'off' ? mode : 'ask',
49 codexMinQuotaPercent: Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : 10,
50 }
51}
52
53/**
54 * Read-only compatibility path, `codex-dispatch.json` beside the canonical dispatch file
55 * (`~/.claude/handoffs/` by default); consulted only when the canonical file is missing.
56 */
57export function legacyDispatchPath(dispatchSettingsPath: string): string {
58 const path = dispatchSettingsPath.replace(/\\/g, '/')
59 const cut = path.lastIndexOf('/')
60 return `${cut >= 0 ? path.slice(0, cut) : '.'}/codex-dispatch.json`
61}
62
63export type Activation = 'auto' | 'always'
64/** `activation` option: `auto` (default) starts the console in a session where `/console` is used, `always` in every session. */
65export function activationMode(value: unknown): Activation {
66 return String(value ?? '').trim().toLowerCase() === 'always' ? 'always' : 'auto'
67}
68
69/** `suggestNext` option: `on` (default) offers the way on as the prompt box's Tab suggestion, `off` leaves the box to Claude Code. */
70export function suggestNextMode(value: unknown): 'on' | 'off' {
71 return String(value ?? '').trim().toLowerCase() === 'off' ? 'off' : 'on'
72}
73hooks/logic.ts 945 lines1// Pure logic for console-status: no I/O, so tests can exercise it directly.
2import type { Blocked, ExecutorTask, JobFlag, Project, Snapshot } from '../types'
3import { commitLine, gitLine, prLine, prTransitions } from './git'
4import { parseProjectExecutor } from './dispatch'
5import type { ProjectExecutor } from './dispatch'
6
7export const STALE_DAYS = 3
8export const ROTATE_PERCENT = 50
9const NONE = new Set(['', '無', '沒有', '-', '未知'])
10
11export type RegistryRow = { name: string; statusPath: string; executor?: ProjectExecutor }
12export type Job = { id: string; kind?: 'sync' | 'continue'; fallbackFrom?: 'codex'; fallbackReason?: string; unmanagedSessionId?: string; warning?: string; executor?: 'claude' | 'codex'; nativeId?: string; sessionId?: string; jobClass?: string; status?: string; summary?: string; createdAt?: string; updatedAt?: string; completedAt?: string; startedAt?: string; phase?: string; logFile?: string; request?: { prompt?: string; effort?: string; model?: string } }
13export type Agent = { name?: string; kind?: string; status?: string; state?: string; waitingFor?: string; sessionId?: string; cwd?: string; pid?: number | null; startedAt?: number | string }
14
15/**
16 * Rows of the "STATUS 卡位置" table in projects-scope.md; `~` expanded to `home`.
17 * An optional `Executor` header column (claude | codex | manual | blank) sets a project's executor;
18 * tables without that column parse exactly as before.
19 */
20export function parseRegistry(text: string, home: string): RegistryRow[] {
21 const parts = lf(text).split('## STATUS 卡位置')
22 if (parts.length < 2) return []
23 const section = parts[1].split('\n## ')[0]
24 const rows: RegistryRow[] = []
25 const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
26 let executorColumn = -1
27 for (const line of section.split('\n')) {
28 const m = line.match(/^\|\s*([^|]+?)\s*\|\s*`([^`]+)`/)
29 if (!m) {
30 if (/^\s*\|/.test(line) && !/^\s*\|[\s|:-]*$/.test(line)) {
31 const header = cells(line).findIndex(cell => /^executor$/i.test(cell))
32 if (header >= 0) executorColumn = header
33 }
34 continue
35 }
36 const row: RegistryRow = { name: m[1], statusPath: m[2].replace(/^~/, home).replace(/\\/g, '/') }
37 const executor = executorColumn >= 0 ? parseProjectExecutor(cells(line)[executorColumn]?.replace(/`/g, '')) : undefined
38 if (executor) row.executor = executor
39 rows.push(row)
40 }
41 return rows
42}
43
44/** CRLF (or a lone CR) as LF: a STATUS or registry saved on Windows reads like any other. */
45const lf = (text: string) => text.replace(/\r\n?/g, '\n')
46
47/** `- key:value` lines between `<!-- CARD ... -->` and `<!-- /CARD -->`; null when absent. */
48export function parseCard(text: string): Record<string, string> | null {
49 const m = lf(text).match(/<!-- CARD[\s\S]*?-->([\s\S]*?)<!-- \/CARD -->/)
50 if (!m) return null
51 const card: Record<string, string> = {}
52 for (const line of m[1].split('\n')) {
53 const km = line.match(/^\s*-\s*([^::]+)[::]\s*(.*)$/)
54 if (km) card[km[1].trim()] = km[2].trim()
55 }
56 return card
57}
58
59export type Gate = { kind: 'spec' | 'review' | 'release' | 'unknown'; detail: string }
60
61/** A CARD gate; supported kinds use `kind: detail`, while unknown text stays intact. */
62export function parseGate(value?: string): Gate | null {
63 const raw = (value ?? '').trim()
64 if (!raw || /^(?:無|none)$/i.test(raw)) return null
65 const match = raw.match(/^(spec|review|release)\s*[::]\s*(.*)$/i)
66 if (!match) return { kind: 'unknown', detail: raw }
67 return { kind: match[1].toLowerCase() as Gate['kind'], detail: match[2].trim() }
68}
69
70/**
71 * The CARD's 更新 field in ms, or null. Executors write it by hand: `2030-01-05 12:00`, but also
72 * `2030/1/5 9:05`, with seconds, or as an instant with a zone (`2030-01-05T04:00:00Z`, `+08:00`).
73 * A zone is honoured; without one it is local time.
74 */
75export function cardTime(updated: string): number | null {
76 const m = updated.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:[ T]+|\s*T\s*)(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?/i)
77 if (!m) return null
78 const [y, mo, d, h, mi, sec] = [+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +(m[6] ?? 0)]
79 const zone = m[7]
80 if (!zone) return new Date(y, mo, d, h, mi, sec).getTime()
81 const offset = /^z$/i.test(zone) ? 0 : (zone[0] === '-' ? -1 : 1) * (+zone.slice(1, 3) * 60 + +zone.slice(-2))
82 return Date.UTC(y, mo, d, h, mi, sec) - offset * 60_000
83}
84
85/** The job a CARD names in its 更新 field (`· job <id>`, as the STATUS template asks), or ''. */
86export function cardJob(updated: string): string {
87 return updated.match(/\bjob\s+[`"]?([\w:.-]{4,})/i)?.[1] ?? ''
88}
89
90/** Project root that owns a STATUS path (`.console/STATUS.md` → repo root). */
91export function projectRoot(statusPath: string): string {
92 const dir = statusPath.replace(/\/[^/]*$/, '')
93 return dir.endsWith('/.console') ? dir.slice(0, -'/.console'.length) : dir
94}
95
96/** Running jobs, and finished jobs newer than the CARD (results the CARD doesn't reflect). */
97export function isActiveJob(job: Job): boolean {
98 if (['completed', 'failed', 'cancelled'].includes(job.status ?? '')) return false
99 return ['running', 'queued', 'starting', 'unknown'].includes(job.status ?? '') || ['starting', 'unknown'].includes(job.phase ?? '')
100}
101
102/**
103 * The CARD was written after a job started, so the job (which ends by updating the CARD) already
104 * wrote its result back. 更新 has minute precision and the job finishes after writing it, so its
105 * completion time is no test: compare with the minute the job started in.
106 */
107export function cardWrittenSince(cardMs: number | null, startedAt: string | undefined): boolean {
108 const start = Date.parse(startedAt ?? '')
109 return cardMs !== null && !Number.isNaN(start) && cardMs >= Math.floor(start / 60_000) * 60_000
110}
111
112/** The CARD names this job as the one that wrote it (the id, its native id, or a launch id ending in it). */
113function namedByCard(job: Job, named: string): boolean {
114 if (!named) return false
115 const ids = [job.id, job.nativeId ?? ''].filter(Boolean)
116 return ids.some(id => id === named || (named.length >= 6 && (id.endsWith(`:${named}`) || id.startsWith(`${named}:`))))
117}
118
119/**
120 * `cardMs`: when the CARD was last written, the later of its 更新 and the STATUS file's modification time;
121 * `named`: the job its 更新 names. Either way a finished job the CARD was written after (or by) is synced.
122 */
123export function jobFlags(jobs: Job[], cardMs: number | null, named = ''): JobFlag[] {
124 const flags: JobFlag[] = []
125 for (const j of jobs) {
126 const summary = (j.summary ?? '').slice(0, 60)
127 if (isActiveJob(j)) {
128 flags.push({ kind: 'running', id: j.id, executor: j.executor, status: j.status ?? 'unknown', summary, startedAt: j.startedAt ?? j.createdAt, phase: j.phase, logFile: j.logFile })
129 continue
130 }
131 if (j.jobClass !== 'task') continue
132 // A finished sync's output is the CARD itself: whether or not it changed it, the sync is not new work.
133 if (j.kind === 'sync' && j.status === 'completed') continue
134 if (j.status === 'completed' && (cardWrittenSince(cardMs, j.startedAt ?? j.createdAt) || namedByCard(j, named))) continue
135 const t = Date.parse(j.completedAt ?? j.createdAt ?? '')
136 // A Claude job whose turn ended on a question to the user (the agent went idle while blocked).
137 const asks = j.status === 'completed' && (j.phase ?? '').startsWith('idle: 等你回覆')
138 if (cardMs === null || (!Number.isNaN(t) && t > cardMs)) flags.push({ kind: 'newer', id: j.id, executor: j.executor, status: j.status ?? '?', summary, ...(j.kind ? { task: j.kind } : {}), ...(asks ? { asks: true, ...(j.sessionId ? { sessionId: j.sessionId } : {}) } : {}) })
139 }
140 return flags
141}
142
143/** The verdict part of a 驗證 line: after `→`/`->`, else after the backticked command, else none. */
144export function verifyNote(line: string): string {
145 const quoted = /^`[^`]+`/.test(line)
146 const rest = line.replace(/^`[^`]+`/, '')
147 const arrow = rest.match(/(?:→|->)\s*(.*)$/)
148 if (arrow) return (arrow[1] ?? '').trim()
149 return quoted ? rest.trim() : ''
150}
151
152/**
153 * `statusMtime`: when STATUS.md was last written, by the clock that also stamps the jobs. The 更新 text is
154 * written by an executor that may guess the time, write UTC or keep an old value, so the file's own
155 * time decides whether a job's result reached the CARD whenever it is later.
156 */
157export function buildProject(row: RegistryRow, cardText: string | null, jobs: Job[], now: number, statusMtime?: number): Project {
158 const card = cardText === null ? null : parseCard(cardText)
159 const updated = card?.['更新'] ?? ''
160 const ms = cardTime(updated)
161 const mtime = card !== null && typeof statusMtime === 'number' && Number.isFinite(statusMtime) && statusMtime > 0 ? statusMtime : null
162 const written = ms === null ? mtime : mtime === null ? ms : Math.max(ms, mtime)
163 return {
164 name: row.name,
165 statusPath: row.statusPath,
166 hasCard: card !== null,
167 state: card?.['狀態'] ?? '',
168 ask: card?.['等使用者'] ?? '',
169 next: card?.['下一步'] ?? '',
170 gate: card?.['關卡'] ?? '',
171 verify: (card?.['驗證'] ?? '').replace(/^`([^`]+)`.*$/, '$1'),
172 verifyNote: verifyNote(card?.['驗證'] ?? ''),
173 updated,
174 isStale: ms !== null && now - ms > STALE_DAYS * 86400000,
175 jobs: jobFlags(jobs, written, cardJob(updated)),
176 ...(mtime !== null ? { statusMtime: mtime } : {}),
177 tasks: jobTasks(jobs),
178 }
179}
180
181/** A CARD value that says "nothing": a none word alone or before punctuation or a note (「無;…」, 「無(等使用者決定)」). */
182export const saysNone = (value: string) => /^(?:無|沒有|none|n\/a|-)(?:$|[;;,,。\s((])/i.test(value.trim())
183
184/** An ask counts unless it is empty or starts with a "none" word (e.g. 「無;示範結果等待整理」). */
185export const hasAsk = (p: Project) =>
186 p.hasCard && !NONE.has(p.ask) && !saysNone(p.ask)
187
188export type Severity = 'ask' | 'running' | 'stale' | 'ok' | 'missing'
189
190/** The single badge a project card shows, most urgent first. */
191export function severity(p: Project): Severity {
192 if (!p.hasCard) return 'missing'
193 if (hasAsk(p)) return 'ask'
194 if (p.jobs.some(j => j.kind === 'running')) return 'running'
195 if (p.isStale || p.jobs.some(j => j.kind === 'newer')) return 'stale'
196 return 'ok'
197}
198
199export type Tile = { label: string; value: string; tone: 'warn' | 'info' | 'bad' | 'quiet' }
200
201/** The summary tiles across the top of the pane. */
202export function tiles(s: Snapshot): Tile[] {
203 const asks = s.projects.filter(hasAsk).length
204 const stale = s.projects.filter(p => severity(p) === 'stale').length
205 const running = s.projects.reduce((n, p) => n + p.jobs.filter(j => j.kind === 'running').length, 0)
206 const ctx = s.contextPercent
207 return [
208 { label: '等你決定', value: String(asks), tone: asks ? 'warn' : 'quiet' },
209 { label: 'CARD 待更新', value: String(stale), tone: stale ? 'info' : 'quiet' },
210 { label: '執行者執行中', value: String(running), tone: running ? 'info' : 'quiet' },
211 { label: 'session 等你', value: String(s.blocked.length), tone: s.blocked.length ? 'warn' : 'quiet' },
212 { label: '主控台 context', value: ctx === null ? '—' : `${ctx}%`, tone: ctx !== null && ctx >= ROTATE_PERCENT ? 'bad' : 'quiet' },
213 ]
214}
215
216/**
217 * Keep the preflight line only when a stale broker belongs to a registered project's workspace
218 * (`STALE codex=V brokers=PID(ws) PID(ws2) ...`); other workspaces' brokers are not ours to report.
219 */
220export function relevantCodex(line: string, projectBases: string[]): string {
221 if (!line.startsWith('STALE')) return line
222 const mine = [...line.matchAll(/(\d+)\(([^)]*)\)/g)].filter(m => projectBases.includes(m[2])).map(m => `${m[1]}(${m[2]})`)
223 if (mine.length === 0) return line.replace(/^STALE/, 'OK*').replace(/ brokers=.*$/, '(其他 workspace 有舊 broker,與本主控台無關)')
224 return `STALE 需處理的 broker:${mine.join(' ')} -> ! for p in ${mine.map(s => s.split('(')[0]).join(' ')}; do taskkill //PID $p //T //F; done`
225}
226
227/** Only an explicit successful probe is healthy; unavailable data stays unknown. */
228export function codexHealth(line: string): 'ok' | 'stale' | 'unknown' {
229 if (line.startsWith('STALE')) return 'stale'
230 return /^OK(?:\*|\s|$)/.test(line) && !/\b(?:UNKNOWN|MISSING)\b/i.test(line) ? 'ok' : 'unknown'
231}
232
233/** Sessions waiting on a person: blocked on a permission prompt, or waiting for input. */
234export function blockedSessions(agents: Agent[], selfName?: string): Blocked[] {
235 return agents
236 .filter(a => {
237 const name = a.name?.trim() ?? ''
238 const hasIdentity = Boolean(name || a.sessionId?.trim() || a.cwd?.trim() || a.waitingFor?.trim())
239 return hasIdentity && name !== selfName && (a.state === 'blocked' || a.status === 'waiting')
240 })
241 .map(a => ({
242 name: a.name?.trim() || '(未命名 session)',
243 why: a.state === 'blocked' ? '等批准' : (a.waitingFor?.trim() || '等輸入'),
244 }))
245}
246
247export function bandText(s: Snapshot): string {
248 const asks = s.projects.filter(hasAsk).length
249 const gates = s.projects.filter(p => parseGate(p.gate) !== null).length
250 const stale = s.projects.filter(p => p.isStale || p.jobs.some(j => j.kind === 'newer')).length
251 const running = s.projects.reduce((n, p) => n + p.jobs.filter(j => j.kind === 'running').length, 0)
252 const parts = [`${s.projects.length} 專案`, `等你 ${asks}`]
253 if (gates) parts.push(`待審核 ${gates}`)
254 if (stale) parts.push(`CARD 待更新 ${stale}`)
255 if (running) parts.push(`執行者執行中 ${running}`)
256 if (s.blocked.length) parts.push(`session 等你 ${s.blocked.length}`)
257 if (s.codex.startsWith('STALE')) parts.push('Codex broker 過期')
258 if (s.contextPercent !== null) parts.push(`context ${s.contextPercent}%${s.contextPercent >= ROTATE_PERCENT ? '(建議換主控台)' : ''}`)
259 if (s.error) parts.push(`讀取錯誤:${s.error}`)
260 return `主控台|${parts.join('|')}`
261}
262
263/** Sessions already waiting in `prev`: all of them, not only the one per project it listed (snapshots before 0.9.5 have only those). */
264const waitingBefore = (prev: Snapshot) => new Set(prev.waiting ?? prev.blocked.map(b => b.name))
265
266/** Toasts for changes since `prev`; the first snapshot (prev null) is only the baseline. */
267export function diffToasts(prev: Snapshot | null, cur: Snapshot): string[] {
268 if (prev === null) return []
269 const out: string[] = []
270 const prevAsk = new Map(prev.projects.map(p => [p.name, hasAsk(p) ? p.ask : '']))
271 for (const p of cur.projects) {
272 if (hasAsk(p) && prevAsk.get(p.name) !== p.ask) out.push(`${p.name} 等你:${p.ask.slice(0, 60)}`)
273 }
274 const prevGate = new Map(prev.projects.map(p => [p.name, parseGate(p.gate) ? (p.gate ?? '').trim() : '']))
275 for (const p of cur.projects) {
276 const gate = parseGate(p.gate)
277 if (gate && prevGate.get(p.name) !== (p.gate ?? '').trim()) out.push(`${p.name} 待審核:${p.gate?.slice(0, 60)}`)
278 }
279 const prevNewer = new Set(prev.projects.flatMap(p => p.jobs.filter(j => j.kind === 'newer').map(j => j.id)))
280 for (const p of cur.projects) {
281 for (const j of p.jobs) if (j.kind === 'newer' && !prevNewer.has(j.id)) {
282 const result = jobResult(j.status)
283 out.push(`${p.name}:執行者 ${result.label}(${j.id})${result.followup ? `,${result.followup}` : ''}`)
284 }
285 }
286 for (const p of cur.projects) {
287 for (const t of prTransitions(p.name, prev.projects.find(x => x.name === p.name)?.pr, p.pr)) if (t.toast) out.push(t.text.replace(' ', ':'))
288 }
289 const prevWaiting = waitingBefore(prev)
290 for (const b of cur.blocked) if (!prevWaiting.has(b.name)) out.push(`session「${b.name}」${b.why}`)
291 if (cur.codex.startsWith('STALE') && !prev.codex.startsWith('STALE')) out.push('Codex app 已更新,broker 過期:派工前請先處理')
292 if (
293 cur.contextPercent !== null && cur.contextPercent >= ROTATE_PERCENT &&
294 (prev.contextPercent === null || prev.contextPercent < ROTATE_PERCENT)
295 ) out.push(`主控台 context 已達 ${cur.contextPercent}%,建議換新的主控台`)
296 return out
297}
298
299/** Example data for `/console demo` and the UI tests; clearly not the person's real projects. */
300export function demoSnapshot(now: number): Snapshot & { demo: true } {
301 const localMinute = (time: number) => {
302 const d = new Date(time)
303 const pad = (n: number) => String(n).padStart(2, '0')
304 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
305 }
306 const card = (ask: string, state: string, age: number, gate = '') =>
307 `<!-- CARD -->\n- 更新:${localMinute(now - age)}(示範)\n- 狀態:${state}\n- 驗證:\`php tests/run.php\` → PASS\n- 等使用者:${ask}\n${gate ? `- 關卡:${gate}\n` : ''}- 下一步:示範資料,/console refresh 換回實際狀態\n<!-- /CARD -->`
308 return {
309 demo: true,
310 at: now,
311 projects: [
312 { ...buildProject({ name: 'Project-Alpha', statusPath: 'demo/a' }, card('選擇示範介面配色:A) 深色主題 B) 淺色主題;示範資料是否保留:1) 保留 2) 清除', '示範測試通過;等待配色選擇', 12 * 60_000), [], now),
313 git: { branch: 'demo/theme', upstream: 'origin/demo/theme', ahead: 1, behind: 0, changed: 3, untracked: 1, conflicts: 0 } },
314 { ...buildProject({ name: 'Project-Beta', statusPath: 'demo/b' }, card('無;示範結果等待整理', '示範文件已整理', 3 * 3_600_000),
315 [{ id: 'task-demo-new', jobClass: 'task', status: 'completed', completedAt: new Date(now - 60_000).toISOString() }], now),
316 git: { branch: 'demo/docs', upstream: 'origin/demo/docs', ahead: 0, behind: 0, changed: 0, untracked: 0, conflicts: 0 },
317 pr: { number: 42, title: '示範:整理文件', state: 'OPEN', draft: false, url: '', checks: { pass: 3, fail: 1, pending: 0, failing: ['demo-lint'] } } },
318 buildProject({ name: 'Project-Gamma', statusPath: 'demo/gamma' }, card('無', '示範證據已備妥', 86_400_000, 'review: 確認示範審核結果'), [], now),
319 buildProject({ name: 'Sample-Docs', statusPath: 'demo/c' }, card('無', '示範工作執行中', 4 * 86_400_000),
320 [
321 { id: 'task-demo-run', jobClass: 'task', status: 'running', executor: 'codex', startedAt: new Date(now - 8 * 60_000).toISOString(), request: { prompt: '示範任務:檢查合成 API 文件', model: 'codex-demo-running', effort: 'medium' } },
322 { id: 'task-demo-done', jobClass: 'task', status: 'completed', executor: 'codex', completedAt: new Date(now - 60 * 60_000).toISOString(), request: { prompt: '示範任務:整理合成測試結果', model: 'codex-demo-completed', effort: 'high' } },
323 ], now),
324 { ...buildProject({ name: 'Sample-API', statusPath: 'demo/api' }, card('無', '示範 API 穩定', 2 * 86_400_000), [], now),
325 git: { branch: 'main', upstream: 'origin/main', ahead: 0, behind: 0, changed: 0, untracked: 0, conflicts: 0 } },
326 ],
327 blocked: [{ name: '示範 session', why: '等批准' }],
328 executor: 'claude', codex: 'OK demo', contextPercent: 62, error: null,
329 limits: [
330 { kind: 'five_hour', percent: 31, resetsAt: new Date(now + 4 * 3_600_000).toISOString() },
331 { kind: 'seven_day', percent: 75, resetsAt: new Date(now + 2 * 86_400_000).toISOString() },
332 { kind: 'seven_day_fable', percent: 48, resetsAt: new Date(now + 2 * 86_400_000).toISOString() },
333 ],
334 codexQuota: {
335 at: new Date(now).toISOString(),
336 limits: [{ label: 'Codex 週', percent: 12, resetsAt: new Date(now + 5 * 86_400_000).toISOString() }],
337 credits: '1,000',
338 },
339 }
340}
341
342/** Two recent, synthetic feed entries shown when demo mode opens. */
343export function demoEvents(now: number): Event[] {
344 return [
345 { at: now - 45_000, text: '示範:Codex 任務正在執行', tone: 'teal' },
346 { at: now - 2 * 60_000, text: '示範:審核關卡已建立', tone: 'amber' },
347 ]
348}
349
350export type DecisionOption = { key: string; text: string }
351export type Decision = { title: string; options: DecisionOption[] }
352
353const OPEN = '((「【['
354const CLOSE = '))」】]'
355// An option marker: `A)` `(A)` `A.` `A、` `A:` `1)` `(1)` `①`, at the start or after a space/punctuation.
356const OPTION_MARK = /(^|[\s,,。::、])(?:[((]([A-H]|[1-9])[))]|([A-H]|[1-9])[))]|([A-H])[..、::](?=\s*\S)|([①-⑨]))\s*/g
357
358const FIRST_KEYS = ['A', '1', '①']
359const optionKey = (m: RegExpMatchArray) => (m[2] ?? m[3] ?? m[4] ?? m[5])!
360const nextKey = (key: string) => String.fromCodePoint(key.codePointAt(0)! + 1)
361
362/** Split on `;` `;` and newlines that are not inside brackets (commands in parentheses stay whole). */
363function splitDecisions(text: string): string[] {
364 const out: string[] = []
365 let depth = 0
366 let buf = ''
367 let code = false
368 for (const ch of text) {
369 // `cd app; npm test` is one command: a backtick span never splits, except at a line break.
370 if (ch === '`') code = !code
371 else if (ch === '\n') code = false
372 else if (!code && OPEN.includes(ch)) depth++
373 else if (!code && CLOSE.includes(ch)) depth = Math.max(0, depth - 1)
374 if (!code && depth === 0 && (ch === ';' || ch === ';' || ch === '\n')) {
375 if (buf.trim()) out.push(buf.trim())
376 buf = ''
377 continue
378 }
379 buf += ch
380 }
381 if (buf.trim()) out.push(buf.trim())
382 return out
383}
384
385/**
386 * A CARD ask as separate decisions, each with its lettered or numbered options when it lists
387 * at least two (`選配色:A) 深色 B) 淺色;是否上線`). Text without markers stays one decision.
388 */
389export function parseAsk(ask: string): Decision[] {
390 return splitDecisions(ask.replace(/\\n/g, '\n')).map(part => {
391 // Keep the last run that counts up from A / 1 / ①, so a stray `Plan A.` is not an option.
392 let marks: RegExpMatchArray[] = []
393 for (const m of part.matchAll(OPTION_MARK)) {
394 const k = optionKey(m)
395 if (FIRST_KEYS.includes(k)) marks = [m]
396 else if (marks.length && k === nextKey(optionKey(marks[marks.length - 1]))) marks.push(m)
397 }
398 if (marks.length < 2) return { title: part, options: [] }
399 const start = (m: RegExpMatchArray) => (m.index ?? 0) + m[1].length
400 const options = marks.map((m, i) => ({
401 key: optionKey(m),
402 text: part.slice((m.index ?? 0) + m[0].length, i + 1 < marks.length ? start(marks[i + 1]) : part.length).trim().replace(/[,,、]$/, ''),
403 }))
404 return { title: part.slice(0, start(marks[0])).trim().replace(/[::,,]$/, ''), options }
405 })
406}
407
408/** A long CARD value (下一步, 狀態) as its `;`-separated clauses, brackets kept whole. */
409export function splitClauses(text: string): string[] {
410 return splitDecisions(text.replace(/\\n/g, '\n'))
411}
412
413/**
414 * The reply the console expects for picked options: `1A 2B`, a numeric key set apart (`2-1`),
415 * and a decision without options left as `3:` for the person to finish.
416 */
417export function decisionAnswer(decisions: Decision[], picks: Record<string, string>): string {
418 return decisions.map((d, i) => {
419 const key = picks[String(i)]
420 if (!d.options.length || !key) return `${i + 1}:`
421 return /^[A-Z]$/.test(key) ? `${i + 1}${key}` : `${i + 1}-${key}`
422 }).join(' ')
423}
424
425/**
426 * Picks after one key in the decision card: digit N takes option N of the first decision still
427 * open (starting over once all are answered), Backspace drops the latest pick. `null` = no change.
428 */
429export function pickDecision(decisions: Decision[], picks: Record<string, string>, key: string): Record<string, string> | null {
430 if (key === 'backspace' || key === 'delete') {
431 const last = Object.keys(picks).map(Number).sort((a, b) => b - a)[0]
432 if (last === undefined) return null
433 const rest = { ...picks }
434 delete rest[String(last)]
435 return rest
436 }
437 const n = Number(key)
438 if (!Number.isInteger(n) || n < 1) return null
439 const choosable = decisions.map((d, i) => d.options.length ? i : -1).filter(i => i >= 0)
440 const open = choosable.find(i => picks[String(i)] === undefined)
441 const base = open === undefined ? {} : picks
442 const at = open ?? choosable[0]
443 const option = at === undefined ? undefined : decisions[at].options[n - 1]
444 return option ? { ...base, [String(at)]: option.key } : null
445}
446
447/** One line for narrow places: every decision title, options folded away. */
448export function askSummary(ask: string): string {
449 const decisions = parseAsk(ask)
450 const titles = decisions.map(d => d.title || d.options.map(o => o.text).join(' / ')).join('|')
451 return decisions.length > 1 ? `${decisions.length} 項決策:${titles}` : titles
452}
453
454/** First clause of a CARD ask, short enough for one line (ADHD-style: action, not context). */
455export function shortAsk(ask: string, max = 34): string {
456 const first = ask.split(/[;;。]|(|\(/)[0].trim()
457 return first.length > max ? first.slice(0, max - 1) + '…' : first
458}
459
460export type Item = { text: string; tone: 'warn' | 'bad' | 'info' | 'win' }
461
462/** What the person should do now, most urgent first; each one line. */
463export function todo(s: Snapshot): Item[] {
464 const items: Item[] = []
465 if (s.error) items.push({ text: `修正:${s.error}`, tone: 'bad' })
466 if (s.codex.startsWith('STALE')) items.push({ text: '處理過期的 Codex broker(面板有指令)', tone: 'bad' })
467 for (const b of s.blocked) items.push({ text: `回應 session「${b.name}」(${b.why})`, tone: 'warn' })
468 for (const p of s.projects) if (hasAsk(p)) items.push({ text: `${p.name.replace(/\s.*$/, '')}:${shortAsk(p.ask)}`, tone: 'warn' })
469 for (const p of s.projects) if (!hasAsk(p) && parseGate(p.gate)) items.push({ text: `${p.name.replace(/\s.*$/, '')}:審核 ${shortAsk(p.gate ?? '')}`, tone: 'warn' })
470 if (s.contextPercent !== null && s.contextPercent >= ROTATE_PERCENT) items.push({ text: `換新主控台(context ${s.contextPercent}%)`, tone: 'info' })
471 return items
472}
473
474/** Executor work in flight, and results that landed since the CARD (visible wins). */
475export function running(s: Snapshot): string[] {
476 return s.projects.flatMap(p => p.jobs.filter(j => j.kind === 'running').map(() => `${p.name.replace(/\s.*$/, '')}:執行者執行中`))
477}
478export function wins(s: Snapshot): string[] {
479 return s.projects.flatMap(p => p.jobs.filter(j => j.kind === 'newer').map(j => `${p.name.replace(/\s.*$/, '')}:執行者 ${j.status === 'completed' ? '完成' : j.status},待寫入 CARD`))
480}
481
482/** The one-line band: lead with the next action. */
483export function focusLine(s: Snapshot): string {
484 const items = todo(s)
485 const run = running(s).length
486 const tail = [items.length > 1 ? `另有 ${items.length - 1} 件` : '', run ? `執行中 ${run}` : '', wins(s).length ? `✓ ${wins(s).length} 件新結果` : '']
487 .filter(Boolean).join('|')
488 const head = items.length ? `▶ 下一步:${items[0].text}` : '✓ 沒有要你處理的事'
489 return tail ? `${head}|${tail}` : head
490}
491
492export type Card = { id: string; title: string; text: string; meta: string }
493export type Column = { key: 'ask' | 'run' | 'done' | 'idle'; label: string; cards: Card[] }
494
495const short = (name: string) => name.replace(/\s.*$/, '')
496
497/** The four board columns of the design: 等你 / 進行中 / 剛完成 / 待命. */
498export function board(s: Snapshot): Column[] {
499 const ask: Card[] = []
500 if (s.error) ask.push({ id: 'err', title: '主控台', text: s.error, meta: '讀取錯誤' })
501 if (s.codex.startsWith('STALE')) ask.push({ id: 'codex', title: 'Codex', text: '處理過期的 broker', meta: '派工前' })
502 for (const b of s.blocked) ask.push({ id: 'b-' + b.name, title: b.name, text: b.why, meta: 'session' })
503 for (const p of s.projects) if (hasAsk(p)) ask.push({ id: 'a-' + p.name, title: short(p.name), text: shortAsk(p.ask, 40), meta: hhmm(p.updated) })
504 const run: Card[] = s.projects.flatMap(p =>
505 p.jobs.filter(j => j.kind === 'running').map(j => ({ id: 'r-' + j.id, title: short(p.name), text: '執行者執行中', meta: j.id.slice(5, 13) })))
506 const done: Card[] = s.projects.flatMap(p =>
507 p.jobs.filter(j => j.kind === 'newer').map(j => ({ id: 'd-' + j.id, title: short(p.name), text: shortAsk(j.summary || `執行者 ${j.status}`, 40), meta: '待寫入 CARD' })))
508 const busy = new Set([...ask, ...run, ...done].map(c => c.title))
509 const idle: Card[] = s.projects
510 .filter(p => !busy.has(short(p.name)))
511 .map(p => ({ id: 'i-' + p.name, title: short(p.name), text: shortAsk(p.state || '—', 40), meta: hhmm(p.updated) }))
512 return [
513 { key: 'ask', label: '等你', cards: ask },
514 { key: 'run', label: '進行中', cards: run },
515 { key: 'done', label: '剛完成', cards: done },
516 { key: 'idle', label: '待命', cards: idle },
517 ]
518}
519
520/** `2030-01-05 09:40(job…)` → `09:40`; other dates → `MM/DD`. */
521export function hhmm(updated: string): string {
522 const m = updated.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/)
523 if (!m) return ''
524 const today = new Date()
525 const isToday = +m[1] === today.getFullYear() && +m[2] === today.getMonth() + 1 && +m[3] === today.getDate()
526 return isToday ? m[4] : `${m[2]}/${m[3]}`
527}
528
529export type State = 'ACTION' | 'GATE' | 'RUNNING' | 'SYNC' | 'IDLE' | 'NOCARD'
530export type Row = { state: State; project: string; item: string; age: string; full: string }
531export const STATE_ORDER: State[] = ['ACTION', 'GATE', 'RUNNING', 'SYNC', 'IDLE', 'NOCARD']
532
533/** `now - t` as now / 12m / 3h / 4d / >99d; a future timestamp has no age. */
534export function ago(t: number | null, now: number): string {
535 if (t === null || !Number.isFinite(t) || !Number.isFinite(now)) return '—'
536 if (t > now) return '—'
537 const m = Math.floor((now - t) / 60000)
538 if (m === 0) return 'now'
539 if (m < 60) return `${m}m`
540 if (m < 1440) return `${Math.floor(m / 60)}h`
541 const days = Math.floor(m / 1440)
542 return days > 99 ? '>99d' : `${days}d`
543}
544
545/** Terminal display width: CJK and other wide glyphs occupy two columns. */
546export function displayWidth(value: string): number {
547 let width = 0
548 for (const char of value) {
549 const cp = char.codePointAt(0) ?? 0
550 if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f) ||
551 (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x1ab0 && cp <= 0x1aff) ||
552 (cp >= 0x1dc0 && cp <= 0x1dff) || (cp >= 0x20d0 && cp <= 0x20ff) ||
553 (cp >= 0xfe20 && cp <= 0xfe2f)) continue
554 const wide = cp >= 0x1100 && (
555 cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
556 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
557 (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
558 (cp >= 0xfe10 && cp <= 0xfe19) || (cp >= 0xfe30 && cp <= 0xfe6f) ||
559 (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) ||
560 (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)
561 )
562 width += wide ? 2 : 1
563 }
564 return width
565}
566
567/** Width of the project column, fitted to content while leaving room for the item column. */
568export function projectColumnWidth(names: string[]): number {
569 const widest = names.reduce((max, name) => Math.max(max, displayWidth(name)), 0)
570 return Math.max(8, Math.min(18, widest))
571}
572
573/** One row per project, its most urgent state first; neutral, system-style wording. */
574export function rows(s: Snapshot): Row[] {
575 const out = s.projects.map((p): Row => {
576 const name = p.name
577 const age = ago(cardTime(p.updated), s.at)
578 const full = p.name
579 if (!p.hasCard) return { state: 'NOCARD', project: name, item: 'STATUS 卡不存在', age: '—', full }
580 if (hasAsk(p)) return { state: 'ACTION', project: name, item: shortAsk(p.ask, 30), age, full }
581 if (parseGate(p.gate)) return { state: 'GATE', project: name, item: shortAsk(p.gate ?? '', 30), age, full }
582 const running = p.jobs.filter(j => j.kind === 'running')
583 if (running.length) return { state: 'RUNNING', project: name, item: (running.length > 1 ? `${running.length} 個任務・` : '') + runLine(running[0], s.at), age, full }
584 // A manual project has no sync button, so the way on is only taking over the session.
585 if (p.jobs.some(j => j.kind === 'newer' && j.asks)) {
586 // A Claude session that asked can be answered from the console (↩ 回覆); a manual project is only taken over.
587 const item = p.executor === 'manual' ? '執行者在等你回覆:接手該 session'
588 : p.jobs.some(j => j.kind === 'newer' && j.asks && j.sessionId) ? '執行者在等你回覆:↩ 回覆或同步' : '執行者在等你回覆:接手該 session 或同步'
589 return { state: 'ACTION', project: name, item, age, full }
590 }
591 if (p.jobs.some(j => j.kind === 'newer')) return { state: 'SYNC', project: name, item: '結果未同步至 STATUS', age, full }
592 return { state: 'IDLE', project: name, item: shortAsk(p.state || '—', 30), age, full }
593 })
594 return out.sort((a, b) => STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state))
595}
596
597export function counts(s: Snapshot): Record<State, number> {
598 const c: Record<State, number> = { ACTION: 0, GATE: 0, RUNNING: 0, SYNC: 0, IDLE: 0, NOCARD: 0 }
599 for (const r of rows(s)) c[r.state]++
600 return c
601}
602
603/** NEXT: the single most important action (system blockers before project actions). */
604export function next(s: Snapshot): string | null {
605 if (s.error) return `主控台:${s.error}`
606 if (s.codex.startsWith('STALE')) return 'Codex:處理過期的 broker'
607 const all = rows(s)
608 const r = all.find(x => x.state === 'ACTION' || x.state === 'GATE')
609 if (r) return `${r.project}・${r.item}`
610 const sync = all.find(x => x.state === 'SYNC')
611 if (sync) return `${sync.project}・同步執行者結果至 STATUS`
612 return null
613}
614
615const normPath = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
616
617/** Lines of the waiting-sessions list in the pane; the rest is summed up as `…另 N 條`. */
618export const BLOCKED_MAX = 3
619
620/**
621 * A background session the daemon retired stays in `claude agents` with its last state, but with
622 * neither `pid` nor `status`. Nobody can answer it, so it is not a session waiting on a person.
623 */
624const retiredAgent = (a: Agent) => a.kind === 'background' && (a.pid === undefined || a.pid === null) && !a.status?.trim()
625
626/** What the session is actually waiting for: a permission prompt, a question it stopped on, or other input. */
627const BLOCKED_RANK = ['等待批准', '停在提問', '等待輸入']
628function blockedWhy(a: Agent): string {
629 if (a.status === 'waiting') return '等待批准'
630 if (a.status === 'idle' && a.state === 'blocked') return '停在提問'
631 return '等待輸入'
632}
633
634const startedMs = (a: Agent) => typeof a.startedAt === 'number' ? a.startedAt : Date.parse(a.startedAt ?? '') || 0
635
636/** Every session of this console or a registered project, not this one and not retired, that waits on a person, with its project. */
637function waitingAgents(agents: Agent[], selfId: string | null, roots: (string | { root: string; name: string })[], home: string): { agent: Agent; project: { key: string; name: string } }[] {
638 const allowed = roots.map(r => typeof r === 'string' ? { root: normPath(r), name: '' } : { root: normPath(r.root), name: r.name })
639 const consoleDir = normPath(home)
640 const projectOf = (a: Agent): { key: string; name: string } | null => {
641 const cwd = normPath(a.cwd ?? '')
642 const hit = allowed.filter(r => cwd === r.root || cwd.startsWith(r.root + '/')).sort((x, y) => y.root.length - x.root.length)[0]
643 if (hit) return { key: hit.root, name: hit.name }
644 return cwd === consoleDir ? { key: consoleDir, name: '主控台' } : null
645 }
646 // The same sessions blockedSessions lists: with some identity, blocked or waiting.
647 return agents.flatMap(a => {
648 const project = projectOf(a)
649 const listed = project && !(a.sessionId && a.sessionId === selfId) && !retiredAgent(a)
650 && Boolean(a.name?.trim() || a.sessionId?.trim() || a.cwd?.trim() || a.waitingFor?.trim())
651 && (a.state === 'blocked' || a.status === 'waiting')
652 return listed ? [{ agent: a, project }] : []
653 })
654}
655
656/**
657 * The names of every waiting session before the list is cut to one per project. The toasts and the feed
658 * compare these, so an older session of a project that shows up once the newer one is answered is not new.
659 */
660export function waitingNames(agents: Agent[], selfId: string | null, roots: (string | { root: string; name: string })[], home: string): string[] {
661 return waitingAgents(agents, selfId, roots, home).map(({ agent }) => agent.name?.trim() || '(未命名 session)')
662}
663
664/**
665 * Sessions that belong to this console (its folder or a registered project), not this one, waiting on a person:
666 * retired ones left out, the newest per project only, newest first, each named with its project.
667 * `roots` may be bare paths (no project name) or `{ root, name }`.
668 */
669export function relevantBlocked(agents: Agent[], selfId: string | null, roots: (string | { root: string; name: string })[], home: string): Blocked[] {
670 const listed = waitingAgents(agents, selfId, roots, home)
671 // Per project the most pressing wording wins (a permission prompt must not hide behind a newer question); then the newest.
672 const rank = (a: Agent) => BLOCKED_RANK.indexOf(blockedWhy(a))
673 const newest = new Map<string, { agent: Agent; name: string }>()
674 for (const { agent: a, project } of listed) {
675 const seen = newest.get(project.key)
676 const better = !seen || rank(a) < rank(seen.agent) || (rank(a) === rank(seen.agent) && startedMs(a) > startedMs(seen.agent))
677 if (better) newest.set(project.key, { agent: a, name: project.name })
678 }
679 return [...newest.values()]
680 .sort((x, y) => startedMs(y.agent) - startedMs(x.agent))
681 .map(({ agent, name }) => ({ name: agent.name?.trim() || '(未命名 session)', why: blockedWhy(agent), ...(name ? { project: name } : {}) }))
682}
683
684/** The pane's lines for waiting sessions: `name(project):why`, at most `max`, then `…另 N 條`. */
685export function blockedLines(blocked: Blocked[], max = BLOCKED_MAX): string[] {
686 const lines = blocked.slice(0, max).map(b => `${b.name}${b.project ? `(${b.project})` : ''}:${b.why}`)
687 return blocked.length > max ? [...lines, `…另 ${blocked.length - max} 條`] : lines
688}
689
690/**
691 * What rides along with the person's next prompt while a project is selected in the panel:
692 * enough for the console to know which project "this" means, without reading anything else.
693 */
694export function selectionContext(snap: Snapshot | null, full: string | null): string | null {
695 if (!snap || !full) return null
696 const p = snap.projects.find(x => x.name === full)
697 if (!p) return null
698 const lines = [
699 `【主控台面板選取】使用者在 console-status 面板選了專案「${p.name}」。這則提示若沒有另外指明專案,指的就是它。`,
700 `STATUS:${p.statusPath}`,
701 ]
702 if (p.state) lines.push(`狀態:${p.state}`)
703 if (hasAsk(p)) lines.push(`需決策:${p.ask}`)
704 if (parseGate(p.gate)) lines.push(`關卡:${p.gate}`)
705 if (p.next) lines.push(`下一步:${p.next}`)
706 for (const j of p.jobs) lines.push(j.kind === 'running' ? `執行者執行中:${j.id}` : `執行者結果未同步至 STATUS:${j.id}(${j.status})`)
707 if (p.git) lines.push(`Git:${gitLine(p.git)}`)
708 if (p.git?.commits?.[0]) lines.push(`最新提交:${commitLine(p.git.commits[0], snap.at)}`)
709 if (p.pr) lines.push(`PR:${prLine(p.pr)}${p.pr.url ? ` ${p.pr.url}` : ''}`)
710 return lines.join('\n')
711}
712
713export type Limit = { kind: string; percent: number }
714export type Event = { at: number; text: string; tone: 'amber' | 'teal' | 'blue' | 'red' | 'green' }
715
716function jobResult(status: string): { label: string; followup: string; event: string; tone: 'blue' | 'red' } {
717 if (status === 'completed') return { label: '完成', followup: 'CARD 待更新', event: '完成,待同步', tone: 'blue' }
718 if (status === 'failed') return { label: '失敗', followup: '請檢查任務', event: '失敗,請檢查', tone: 'red' }
719 if (status === 'cancelled') return { label: '已取消', followup: '', event: '已取消', tone: 'red' }
720 return { label: status || '狀態未知', followup: '請檢查任務', event: status || '狀態未知', tone: 'red' }
721}
722
723/** `█████░░░` for a 0–100 value in `width` cells. */
724export function meter(percent: number, width = 8): string {
725 const full = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
726 return '█'.repeat(full) + '░'.repeat(width - full)
727}
728
729/** A window kind split into its window and the model it is scoped to (`seven_day_fable` → seven_day, fable); null when the kind is not a window. */
730export function parseLimitKind(kind: string): { window: 'five_hour' | 'seven_day'; model: string } | null {
731 const m = /^(five_hour|5_hour|5h|seven_day|7_day|7d|weekly|week)(?:[_-]([a-z0-9]+))?$/i.exec(kind.trim())
732 if (!m) return null
733 return { window: /^(five_hour|5_hour|5h)$/i.test(m[1]) ? 'five_hour' : 'seven_day', model: m[2] ?? '' }
734}
735
736/** Spellings of the model names the host scopes windows to; any other name is shown as sent. */
737const MODEL_NAMES: Record<string, string> = { fable: 'Fable', opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }
738
739/** The model a per-model window kind names (`seven_day_fable` → `Fable`), or '' for a window shared by every model. */
740export function limitModel(kind: string): string {
741 const model = parseLimitKind(kind)?.model ?? ''
742 return model ? MODEL_NAMES[model.toLowerCase()] ?? model : ''
743}
744
745/** Readable name of a rate-limit window kind: `five_hour` → `5 小時`, `seven_day` → `本週`, `seven_day_fable` → `Fable 週`, `spend_limit` → `花費上限`; a kind it does not know keeps its name. */
746export function limitName(kind: string): string {
747 const parsed = parseLimitKind(kind)
748 if (!parsed) return kind === 'spend_limit' ? '花費上限' : kind
749 const model = limitModel(kind)
750 const window = parsed.window === 'five_hour' ? '5 小時' : model ? '週' : '本週'
751 return model ? `${model} ${window}` : window
752}
753
754/** Hover help for a usage row, naming the model when the window is scoped to one. */
755export function limitHelp(kind: string): string {
756 const base = limitHelpBase(kind)
757 return parseLimitKind(kind) ? `${base}照這個視窗開始以來的平均速度會撐不到重置時,後面會標出約多久後用完。` : base
758}
759
760function limitHelpBase(kind: string): string {
761 const parsed = parseLimitKind(kind)
762 const model = limitModel(kind)
763 const name = limitName(kind)
764 if (parsed && model) {
765 return parsed.window === 'five_hour'
766 ? `${name}:Claude 帳號 5 小時滾動額度中 ${model} 專用的剩餘量,與整體「5 小時」分開計算,到重置時間回滿。`
767 : `${name}:Claude 帳號本週 ${model} 專用額度的剩餘量,與整體「本週」分開計算,到重置時間回滿。`
768 }
769 if (parsed?.window === 'five_hour') return '5 小時:Claude 帳號 5 小時滾動額度的剩餘量,到重置時間回滿。'
770 if (parsed) return '本週:Claude 帳號每週額度的剩餘量。'
771 if (kind === 'spend_limit') return '花費上限:Claude gateway 為這個帳號設定的花費上限還剩多少,超額後電池見底,到週期重置時回滿。'
772 return `${kind}:host 回報的額度視窗,名稱照原字串顯示。`
773}
774
775/** The activity feed: what changed between two snapshots, newest first, as short system lines. */
776export function events(prev: Snapshot | null, cur: Snapshot): Event[] {
777 if (prev === null) return []
778 const out: Event[] = []
779 const short = (n: string) => n.replace(/\s.*$/, '')
780 const prevAsk = new Map(prev.projects.map(p => [p.name, hasAsk(p) ? p.ask : '']))
781 const prevGate = new Map(prev.projects.map(p => [p.name, parseGate(p.gate) ? (p.gate ?? '').trim() : '']))
782 const prevJobs = new Map(prev.projects.flatMap(p => p.jobs.map(j => [j.id, j.kind] as const)))
783 const prevUpd = new Map(prev.projects.map(p => [p.name, p.updated]))
784 for (const p of cur.projects) {
785 if (hasAsk(p) && prevAsk.get(p.name) !== p.ask) out.push({ at: cur.at, text: `${short(p.name)} 新的決策項目`, tone: 'amber' })
786 const gate = parseGate(p.gate)
787 if (gate && prevGate.get(p.name) !== (p.gate ?? '').trim()) out.push({ at: cur.at, text: `${short(p.name)} 新的審核關卡`, tone: 'amber' })
788 if (prevUpd.get(p.name) !== p.updated && prevUpd.has(p.name)) out.push({ at: cur.at, text: `${short(p.name)} STATUS 已更新`, tone: 'green' })
789 for (const j of p.jobs) {
790 const was = prevJobs.get(j.id)
791 if (j.kind === 'running' && was !== 'running') out.push({ at: cur.at, text: `${short(p.name)} 執行者開始執行`, tone: 'teal' })
792 if (j.kind === 'newer' && was !== 'newer') {
793 const result = jobResult(j.status)
794 const executor = j.executor === 'codex' ? 'Codex' : j.executor === 'claude' ? 'Claude' : '執行者'
795 out.push({ at: cur.at, text: `${short(p.name)} ${executor} ${result.event}`, tone: result.tone })
796 }
797 }
798 }
799 for (const p of cur.projects) {
800 for (const t of prTransitions(short(p.name), prev.projects.find(x => x.name === p.name)?.pr, p.pr)) out.push({ at: cur.at, text: t.text, tone: t.tone })
801 }
802 const prevWaiting = waitingBefore(prev)
803 for (const b of cur.blocked) if (!prevWaiting.has(b.name)) out.push({ at: cur.at, text: `工作階段「${b.name}」${b.why}`, tone: 'amber' })
804 if (cur.codex.startsWith('STALE') && !prev.codex.startsWith('STALE')) out.push({ at: cur.at, text: 'Codex broker 過期', tone: 'red' })
805 return out
806}
807
808/**
809 * A phone-style battery of what is LEFT (100 − used): the body is `width` cells with the
810 * remaining percent centred in it, split at the fill edge so each half takes its own background.
811 * Spaces and ASCII only: block glyphs render double-width in CJK terminals.
812 */
813export function battery(usedPercent: number, width = 10, low = 40, critical = 20): { left: number; tone: 'green' | 'low' | 'red'; filled: string; empty: string } {
814 const left = Math.max(0, Math.min(100, Math.round(100 - usedPercent)))
815 const label = `${left}%`
816 const start = Math.floor((width - label.length) / 2)
817 const body = (' '.repeat(start) + label).padEnd(width)
818 const cut = left > 0 ? Math.max(1, Math.round((left / 100) * width)) : 0
819 return { left, tone: left <= critical ? 'red' : left <= low ? 'low' : 'green', filled: body.slice(0, cut), empty: body.slice(cut) }
820}
821
822/** The project behind `next(s)`, when it is a project row (so the bar can select it). */
823export function nextProject(s: Snapshot): string | null {
824 if (s.error || s.codex.startsWith('STALE')) return null
825 const all = rows(s)
826 return (all.find(x => x.state === 'ACTION' || x.state === 'GATE') ?? all.find(x => x.state === 'SYNC'))?.full ?? null
827}
828
829/** `重置 17:00・2 小時 13 分後` (same day) or `重置 10/8 (三) 09:00・2 天 4 小時後`. */
830export function resetText(iso: string | undefined, now: number, compact = false): string {
831 if (!iso) return ''
832 const t = Date.parse(iso)
833 if (!Number.isFinite(t)) return ''
834 const d = new Date(t)
835 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
836 const sameDay = new Date(now).toDateString() === d.toDateString()
837 const when = sameDay ? hm : `${d.getMonth() + 1}/${d.getDate()} (${'日一二三四五六'[d.getDay()]}) ${hm}`
838 const mins = Math.max(0, Math.round((t - now) / 60000))
839 const dd = Math.floor(mins / 1440), hh = Math.floor((mins % 1440) / 60), mm = mins % 60
840 const left = dd ? `${dd} 天 ${hh} 小時後` : hh ? `${hh} 小時 ${mm} 分後` : `${mm} 分後`
841 // A narrow pane keeps the countdown, the part that decides whether to wait.
842 if (compact) return `${dd ? `${dd}天${hh}時` : hh ? `${hh}時${mm}分` : `${mm}分`}後重置`
843 return `重置 ${when}・${left}`
844}
845
846/** Last meaningful line of an executor job log, trimmed for one table cell. */
847export function lastLogLine(log: string): string {
848 const lines = log.split(/\r?\n/).map(l => l.trim()).filter(l => l && !/^[-=─*`]+$/.test(l))
849 return (lines.pop() ?? '').replace(/^\[[^\]]*\]\s*/, '').slice(0, 80)
850}
851
852/** `已跑 12m・<last log line or phase>` for a running job. */
853export function runLine(j: JobFlag, now: number): string {
854 const t = Date.parse(j.startedAt ?? '')
855 const elapsed = Number.isNaN(t) ? '' : `已跑 ${ago(t, now)}`
856 const detail = j.last || (j.phase && j.phase !== 'running' ? j.phase : '') || ''
857 if (!elapsed && !detail) return j.executor ? `${j.executor} 執行中` : '執行中'
858 const body = elapsed && detail ? `${elapsed}・${detail}` : elapsed || detail
859 return (j.executor ? `${j.executor} · ` : '') + body
860}
861
862/** A task's name: the prompt's first meaningful line, without markdown and a leading `任務:`. */
863export function taskTitle(prompt: string | undefined, fallback: string): string {
864 const line = (prompt ?? '').split(/\r?\n/).map(l => l.replace(/^#+\s*/, '').trim()).find(l => l && !l.startsWith('<!--'))
865 return (line ?? fallback).replace(/^任務[::]\s*/, '').slice(0, 60) || fallback
866}
867
868const LIVE = new Set(['running', 'queued'])
869
870/** Every running or queued task, then the three most recently finished. */
871export function jobTasks(jobs: Job[]): ExecutorTask[] {
872 const tasks = jobs.filter(j => j.jobClass === 'task').map((j): ExecutorTask => ({
873 id: j.id,
874 status: j.status ?? '?',
875 ...(j.executor ? { executor: j.executor } : {}),
876 ...(j.fallbackFrom ? { fallbackFrom: j.fallbackFrom } : {}),
877 title: taskTitle(j.request?.prompt, j.summary ?? j.id),
878 model: j.request?.model ?? '',
879 effort: j.request?.effort ?? '',
880 startedAt: j.startedAt ?? j.createdAt,
881 completedAt: j.completedAt,
882 }))
883 const live = tasks.filter(x => LIVE.has(x.status))
884 const done = tasks.filter(x => !LIVE.has(x.status))
885 .sort((a, b) => (Date.parse(b.completedAt ?? '') || 0) - (Date.parse(a.completedAt ?? '') || 0))
886 .slice(0, 3)
887 return [...live, ...done]
888}
889
890/** Compact a model id for one-line task metadata without knowing model names. */
891export function shortModel(model: string, max = 14): string {
892 let value = model.trim().replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').replace(/^.*\//, '')
893 value = value.replace(/^[a-z]+[-_]\d+(?:\.\d+)*(?:[-_])?/i, '') || value
894 return value.length > max ? value.slice(0, Math.max(1, max - 1)) + '…' : value
895}
896
897/** Icon, tone and the short right-hand meta of one task line. */
898export function taskMeta(t: ExecutorTask, now: number): { icon: string; tone: 'teal' | 'dim' | 'green' | 'red'; meta: string } {
899 const settings = [shortModel(t.model ?? ''), t.effort, t.fallbackFrom ? `${t.fallbackFrom}→claude` : ''].filter(Boolean).join(' · ')
900 const suffix = settings ? ` · ${settings}` : ''
901 if (t.status === 'running') return { icon: '●', tone: 'teal', meta: `已跑 ${ago(Date.parse(t.startedAt ?? ''), now)}${suffix}` }
902 if (t.status === 'queued') return { icon: '○', tone: 'dim', meta: `排隊中${suffix}` }
903 if (t.status === 'failed' || t.status === 'cancelled') return { icon: '✕', tone: 'red', meta: `${t.status === 'failed' ? '失敗' : '已取消'}${suffix}` }
904 if (t.status !== 'completed') return { icon: '?', tone: 'dim', meta: `狀態未知(${t.status || '?'})${suffix}` }
905 return { icon: '✓', tone: 'green', meta: `${ago(Date.parse(t.completedAt ?? ''), now)} 前完成${suffix}` }
906}
907
908/**
909 * Parse `codex-quota.ps1` output (`{ at, rate_limits }`) or the raw rollout event that
910 * `codex-quota.sh` prints (`{ timestamp, payload: { rate_limits | info.rate_limits } }`);
911 * a window past its reset counts as unused.
912 */
913export function parseCodexQuota(text: string, now: number): { at: string; limits: { label: string; percent: number; resetsAt?: string }[]; credits?: string } | null {
914 let d: any
915 try { d = JSON.parse(text.trim().split('\n').pop() ?? '') } catch { return null }
916 const rl = d?.rate_limits ?? d?.payload?.rate_limits ?? d?.payload?.info?.rate_limits
917 if (!rl) return null
918 const limits: { label: string; percent: number; resetsAt?: string }[] = []
919 for (const w of [rl.primary, rl.secondary]) {
920 if (!w || typeof w.used_percent !== 'number') continue
921 const mins = Number(w.window_minutes) || 0
922 const label = mins >= 10000 ? 'Codex 週' : mins >= 280 && mins <= 320 ? 'Codex 5h' : `Codex ${Math.round(mins / 60)}h`
923 const reset = typeof w.resets_at === 'number' ? w.resets_at * 1000 : NaN
924 const past = Number.isFinite(reset) && reset <= now
925 limits.push({ label, percent: past ? 0 : w.used_percent, ...(Number.isFinite(reset) && !past ? { resetsAt: new Date(reset).toISOString() } : {}) })
926 }
927 const bal = rl.credits && !rl.credits.unlimited && rl.credits.balance != null ? String(rl.credits.balance) : undefined
928 return { at: String(d.at ?? d.timestamp ?? ''), limits, ...(bal ? { credits: Number(bal).toLocaleString('en-US') } : {}) }
929}
930
931/**
932 * Warnings a project's jobs carry, as feed lines. A job that had already finished, and was last
933 * updated, before this plugin lifetime began (`since`) is history: after a reload its warning is not
934 * said again. An unfinished job, or one updated since, still speaks.
935 */
936export function jobWarnings(projectName: string, jobs: { warning?: string; status?: string; completedAt?: string; updatedAt?: string }[], since: number): string[] {
937 return jobs.filter(job => {
938 if (!job.warning) return false
939 const done = !!job.completedAt || ['completed', 'failed', 'cancelled'].includes(job.status ?? '')
940 if (!done) return true
941 const updated = Date.parse(job.updatedAt ?? job.completedAt ?? '')
942 return Number.isFinite(updated) && updated >= since
943 }).map(job => `${projectName}:${job.warning}`)
944}
945hooks/dispatch.ts 253 lines1
2export type Executor = 'claude' | 'codex'
3/** A project may also be `manual`: the panel never dispatches it (CARD, verify and gates still work). */
4export type ProjectExecutor = Executor | 'manual'
5/** A sync's own model/effort for one executor; blank fields fall back to the normal dispatch. */
6export type SyncModel = { model?: string; effort?: string }
7/**
8 * `sync` in dispatch.json (globally or per project): a cheaper model for 同步 STATUS, per executor,
9 * and whether the sync runs in a new small session (`fresh`) or resumes the project's (`resume`).
10 * Nothing set means a sync dispatches exactly like a continue does.
11 */
12export type SyncSettings = { session?: 'fresh' | 'resume'; claude?: SyncModel; codex?: SyncModel }
13export type ProjectOverride = { executor?: ProjectExecutor; model?: string; effort?: string; sync?: SyncSettings }
14/**
15 * The canonical dispatch file. `projects` is optional and keyed by project root;
16 * a file without it (the 0.1 flat shape) keeps working unchanged.
17 */
18export type DispatchSettings = { executor: Executor; model: string; effort: string; sync?: SyncSettings; projects?: Record<string, ProjectOverride> }
19export type ExecutorSource = 'pane' | 'registry' | 'global'
20export type EffectiveDispatch = { executor: ProjectExecutor; model: string; effort: string; source: ExecutorSource }
21export const PROJECT_EXECUTORS: ProjectExecutor[] = ['claude', 'codex', 'manual']
22export type ModelOption = { model: string; efforts: string[] }
23const CLAUDE_MODELS: ModelOption[] = ['fable', 'opus', 'sonnet', 'haiku'].map(model => ({ model, efforts: [] }))
24const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
25const CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh']
26const clean = (value: unknown): string | null => typeof value === 'string' && !/[\r\n\x00]/.test(value) ? value.trim() : null
27const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
28
29export function parseSettings(text: string | null, defaults: DispatchSettings): DispatchSettings {
30 try {
31 const data = JSON.parse((text ?? '').replace(/^\uFEFF/, ''))
32 if (object(data)) {
33 const executor = data.executor === 'claude' || data.executor === 'codex' ? data.executor : defaults.executor
34 const settings: DispatchSettings = { executor, model: clean(data.model) ?? defaults.model, effort: clean(data.effort) ?? defaults.effort }
35 const sync = parseSync(data.sync)
36 if (sync) settings.sync = sync
37 const projects = parseProjects(data.projects)
38 if (projects) settings.projects = projects
39 return settings
40 }
41 } catch { /* Missing or partially written file: use user defaults. */ }
42 return { ...defaults }
43}
44
45export type SettingsSource = 'canonical' | 'legacy' | 'defaults'
46
47/**
48 * `canonical` / `legacy` are file texts, or null when that file is missing (unreadable).
49 * The legacy codex-dispatch.json is read only when the canonical file is missing; it is never
50 * written. A legacy file without an `executor` field was a Codex-only file, so it means codex.
51 */
52export function readSettingsFiles(canonical: string | null, legacy: string | null, defaults: DispatchSettings): { settings: DispatchSettings; source: SettingsSource } {
53 if (canonical !== null) return { settings: parseSettings(canonical, defaults), source: 'canonical' }
54 if (legacy !== null) {
55 try {
56 if (object(JSON.parse(legacy.replace(/^/, '')))) return { settings: parseSettings(legacy, { ...defaults, executor: 'codex' }), source: 'legacy' }
57 } catch { /* Malformed legacy file: defaults. */ }
58 }
59 return { settings: { ...defaults }, source: 'defaults' }
60}
61
62function parseSyncModel(value: unknown): SyncModel | null {
63 if (!object(value)) return null
64 const out: SyncModel = {}
65 const model = clean(value.model)
66 const effort = clean(value.effort)
67 if (model) out.model = model
68 if (effort) out.effort = effort
69 return Object.keys(out).length ? out : null
70}
71
72function parseSync(value: unknown): SyncSettings | null {
73 if (!object(value)) return null
74 const out: SyncSettings = {}
75 if (value.session === 'fresh' || value.session === 'resume') out.session = value.session
76 for (const executor of ['claude', 'codex'] as const) {
77 const model = parseSyncModel(value[executor])
78 if (model) out[executor] = model
79 }
80 return Object.keys(out).length ? out : null
81}
82
83function parseProjects(value: unknown): Record<string, ProjectOverride> | null {
84 if (!object(value)) return null
85 const out: Record<string, ProjectOverride> = {}
86 for (const [root, raw] of Object.entries(value)) {
87 if (!root.trim() || !object(raw)) continue
88 const override: ProjectOverride = {}
89 if (raw.executor === 'claude' || raw.executor === 'codex' || raw.executor === 'manual') override.executor = raw.executor
90 const model = clean(raw.model)
91 const effort = clean(raw.effort)
92 if (model) override.model = model
93 if (effort) override.effort = effort
94 const sync = parseSync(raw.sync)
95 if (sync) override.sync = sync
96 if (Object.keys(override).length) out[root] = override
97 }
98 return Object.keys(out).length ? out : null
99}
100
101/** Parse a registry `Executor` cell; blank or unknown text means the global default. */
102export function parseProjectExecutor(value: string | undefined): ProjectExecutor | undefined {
103 const text = (value ?? '').trim().toLowerCase()
104 return text === 'claude' || text === 'codex' || text === 'manual' ? text : undefined
105}
106
107const slashRoot = (root: string) => root.replace(/\\/g, '/').replace(/\/+$/, '')
108
109/** Same normalization as the executors use: forward slashes, Windows drive and UNC paths case-folded. */
110export function projectKey(root: string): string {
111 const normalized = slashRoot(root)
112 return /^(?:[a-z]:\/|\/\/)/i.test(normalized) ? normalized.toLowerCase() : normalized
113}
114
115export function projectOverride(settings: DispatchSettings, root: string): ProjectOverride | undefined {
116 const key = projectKey(root)
117 const entry = Object.entries(settings.projects ?? {}).find(([candidate]) => projectKey(candidate) === key)
118 return entry?.[1]
119}
120
121/**
122 * Precedence: pane override in dispatch.json > registry `Executor` column > global executor.
123 * Model and effort follow the same chain; a project whose executor differs from the global one
124 * does not inherit the global model/effort (they belong to the other executor).
125 */
126export function effectiveDispatch(settings: DispatchSettings, root: string, registry?: ProjectExecutor): EffectiveDispatch {
127 const override = projectOverride(settings, root)
128 const executor = override?.executor ?? registry ?? settings.executor
129 const source: ExecutorSource = override?.executor ? 'pane' : registry ? 'registry' : 'global'
130 const inherit = executor === settings.executor
131 return {
132 executor, source,
133 model: override?.model ?? (inherit ? settings.model : ''),
134 effort: override?.effort ?? (inherit ? settings.effort : ''),
135 }
136}
137
138export type SyncDispatch = {
139 model: string
140 effort: string
141 /** A new small session that reads STATUS and the executor digest, instead of resuming the project's. */
142 fresh: boolean
143 /** The sync has a model or effort of its own (not the normal dispatch's). */
144 custom: boolean
145}
146
147/**
148 * How a 同步 STATUS dispatch runs on `executor`: the project's `sync` fields over the global ones, each blank
149 * field falling back to `base` (the model/effort a continue would use). On Claude, `session` defaults to `fresh` once
150 * a sync model or effort is set (a cheap model in the project's long session would re-cache all of it). Codex stays on
151 * `resume` unless set: the next continue's `--resume-last` would pick up a fresh sync's thread instead of the work's.
152 */
153export function syncDispatch(settings: DispatchSettings, root: string, executor: Executor, base: { model?: string; effort?: string }): SyncDispatch {
154 const project = projectOverride(settings, root)?.sync
155 const own = { ...settings.sync?.[executor], ...project?.[executor] }
156 const custom = !!(own.model || own.effort)
157 const session = project?.session ?? settings.sync?.session
158 return {
159 model: own.model || base.model || '',
160 effort: own.effort || base.effort || '',
161 fresh: session ? session === 'fresh' : custom && executor === 'claude',
162 custom,
163 }
164}
165
166/** Set (or with '' clear) the global sync model, effort or session; empty objects are dropped so the file stays minimal. */
167export function setSyncSetting(settings: DispatchSettings, field: 'model' | 'effort' | 'session', value: string, executor: Executor = settings.executor): DispatchSettings {
168 const text = value.trim()
169 if (/[\r\n\x00]/.test(text)) throw new Error('設定值必須是單行文字。')
170 const sync: SyncSettings = { ...settings.sync }
171 if (field === 'session') {
172 if (text && text !== 'fresh' && text !== 'resume') throw new Error('session 可選:fresh(新的小 session)、resume(接續專案 session);空白=有同步模型時 fresh')
173 if (text) sync.session = text as 'fresh' | 'resume'
174 else delete sync.session
175 } else {
176 const own: SyncModel = { ...sync[executor] }
177 if (text) own[field] = text
178 else delete own[field]
179 if (Object.keys(own).length) sync[executor] = own
180 else delete sync[executor]
181 }
182 const { sync: _old, ...rest } = settings
183 return Object.keys(sync).length ? { ...rest, sync } : rest
184}
185
186/** Set (or with an empty value, clear) one field of a project's override, keeping the file minimal. */
187export function setProjectOverride(settings: DispatchSettings, root: string, field: Exclude<keyof ProjectOverride, 'sync'>, value: string): DispatchSettings {
188 const key = projectKey(root)
189 const projects: Record<string, ProjectOverride> = {}
190 let storedKey = slashRoot(root)
191 for (const [candidate, override] of Object.entries(settings.projects ?? {})) {
192 if (projectKey(candidate) === key) storedKey = candidate
193 else projects[candidate] = { ...override }
194 }
195 const current = { ...projectOverride(settings, root) }
196 if (field === 'executor') {
197 const next = parseProjectExecutor(value)
198 if (value.trim() && !next) throw new Error('executor 可選:claude, codex, manual(空白=沿用預設)')
199 if (next !== current.executor) { delete current.model; delete current.effort }
200 if (next) current.executor = next
201 else delete current.executor
202 } else if (value.trim()) current[field] = value.trim()
203 else delete current[field]
204 if (Object.keys(current).length) projects[storedKey] = current
205 const { projects: _old, ...rest } = settings
206 return Object.keys(projects).length ? { ...rest, projects } : rest
207}
208
209/** Pane cycle for one project: inherit -> claude -> codex -> manual -> inherit. */
210export function nextProjectExecutor(current: ProjectExecutor | undefined): ProjectExecutor | '' {
211 const order: (ProjectExecutor | '')[] = ['', ...PROJECT_EXECUTORS]
212 return order[(order.indexOf(current ?? '') + 1) % order.length] ?? ''
213}
214
215/** Executor selections only: a refresh that sees this change mid-flight is redone. */
216export function executorSignature(settings: DispatchSettings): string {
217 return JSON.stringify([settings.executor, Object.entries(settings.projects ?? {}).map(([root, o]) => [projectKey(root), o.executor ?? ''])])
218}
219
220export function parseModels(text: string | null): ModelOption[] {
221 try {
222 const data = JSON.parse((text ?? '').replace(/^\uFEFF/, ''))
223 const entries = Array.isArray(data) ? data : data?.models
224 if (!Array.isArray(entries)) return []
225 const result: ModelOption[] = []
226 for (const entry of entries) {
227 if (!object(entry)) continue
228 const model = clean(entry.slug) || clean(entry.id)
229 if (!model || result.some(m => m.model === model)) continue
230 const levels = entry.supported_reasoning_levels
231 const efforts = Array.isArray(levels) ? levels.map(v => clean(object(v) ? v.effort : v)).filter((v): v is string => !!v) : []
232 result.push({ model, efforts: [...new Set(efforts)] })
233 }
234 return result
235 } catch { return [] }
236}
237
238/** Models exposed by the selected executor; Claude aliases come from its CLI. */
239export function modelOptions(executor: Executor, codexModels: ModelOption[]): ModelOption[] {
240 return executor === 'claude' ? CLAUDE_MODELS.map(option => ({ ...option, efforts: [...option.efforts] })) : codexModels
241}
242
243export function effortOptions(executor: Executor, models: ModelOption[], model: string): string[] {
244 if (executor === 'claude') return [...CLAUDE_EFFORTS]
245 const options = models.find(m => m.model === model)?.efforts
246 return options?.length ? options : [...CODEX_EFFORTS]
247}
248
249export function nextOption(current: string, options: string[]): string {
250 return options.length ? options[(options.indexOf(current) + 1) % options.length] : current
251}
252
253hooks/executors.ts 585 lines1import type { Job } from './logic'
2import { terminalLines } from './terminal'
3import { isActiveJob } from './logic'
4import { readJobs } from './jobs'
5import { dispatchKind } from './actions'
6
7export type ExecutorKind = 'codex' | 'claude'
8/**
9 * `fresh`: start a new session instead of resuming the project's (an independent review), and never make it the project's session.
10 * `resumeSession`: resume this session instead of the project's (the one a job stopped in to ask the user); when it is not the
11 * project's session (an independent review that asked), the launch stays out of the project's session too.
12 */
13export type DispatchOptions = { model?: string; effort?: string; kind?: 'sync' | 'continue'; fallbackFrom?: 'codex'; fallbackReason?: string; fresh?: boolean; resumeSession?: string }
14export type ExecutorJob = Job & { executor?: ExecutorKind; nativeId?: string; sessionId?: string }
15
16type RunResult = { exitCode: number; stdout: string; stderr: string }
17type RunInit = { cwd?: string; timeoutMs?: number }
18export type ExecutorDeps = {
19 run(argv: readonly string[], init?: RunInit): Promise<RunResult>
20 files: {
21 read(path: string): Promise<string>
22 list(path: string): Promise<{ name: string; kind: string }[]>
23 write(path: string, text: string): Promise<unknown>
24 /** Size and mtime; lets lastLine skip re-reading an unchanged log. Optional for callers without it. */
25 stat?(path: string): Promise<{ size: number; mtimeMs: number }>
26 }
27 now(): number | Promise<number>
28 /**
29 * Every background session (`claude agents --json --all`), shared by all projects in one refresh so
30 * the CLI starts once instead of once per project. Absent: each query runs with `--cwd`.
31 */
32 agents?(): Promise<Agent[]>
33}
34
35/** `fs.read` refuses files over 4 MiB, so a longer log has no readable tail. */
36const LOG_READ_LIMIT = 4 * 1024 * 1024
37const tails = new Map<string, { key: string; line: string }>()
38async function logTail(deps: ExecutorDeps, path: string): Promise<string> {
39 const stat = deps.files.stat ? await deps.files.stat(path).catch(() => null) : null
40 if (stat && stat.size > LOG_READ_LIMIT) return `(log 超過 4 MiB,請直接開啟:${path})`
41 const key = stat ? `${stat.size}|${stat.mtimeMs}` : ''
42 const cached = tails.get(path)
43 if (key && cached?.key === key) return cached.line
44 const line = lastMeaningfulLine((await deps.files.read(path)).slice(-4000))
45 if (key) {
46 tails.delete(path)
47 tails.set(path, { key, line })
48 if (tails.size > 64) tails.delete(tails.keys().next().value!)
49 }
50 return line
51}
52export type ExecutorConfig = {
53 companionScript: string
54 companionStateRoots: string[]
55 claudeSessionsPath: string
56}
57export type Executor = {
58 listJobs(root: string): Promise<ExecutorJob[]>
59 dispatch(root: string, prompt: string, opts: DispatchOptions): Promise<ExecutorJob>
60 lastLine(job: Job): Promise<string>
61}
62
63type Agent = {
64 id?: string
65 name?: string
66 sessionId?: string
67 cwd?: string
68 kind?: string
69 startedAt?: number | string
70 status?: string
71 state?: string
72 waitingFor?: string
73 /** The agent's process; the daemon drops it (and `status`) when it retires an idle session. */
74 pid?: number
75}
76type ClaudeJob = {
77 id: string
78 kind?: 'sync' | 'continue'
79 /** Never becomes the project's session: launched without `--resume` (an independent review), or resuming one. */
80 fresh?: boolean
81 /** Set when a Codex dispatch was sent to Claude by the quota/broker fallback. */
82 fallbackFrom?: 'codex'
83 fallbackReason?: string
84 nativeId?: string
85 launchName?: string
86 sessionId?: string
87 unmanagedSessionId?: string
88 warning?: string
89 root: string
90 prompt: string
91 model?: string
92 effort?: string
93 startedAt: string
94 updatedAt?: string
95 completedAt?: string
96 status: string
97 phase: string
98}
99type RootState = { root: string; sessionId?: string; jobs: ClaudeJob[] }
100type ClaudeState = { version: 1; roots: Record<string, RootState> }
101
102const MAX_HISTORY = 20
103const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
104const SHORT_ID = /^[0-9a-f]{8}$/i
105const locks = new Map<string, Promise<void>>()
106
107const slash = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, '')
108const rootKey = (value: string) => {
109 const normalized = slash(value)
110 return /^(?:[a-z]:\/|\/\/)/i.test(normalized) ? normalized.toLowerCase() : normalized
111}
112const iso = (value: number) => new Date(value).toISOString()
113const cleanLine = (value: string) => terminalLines(value).join(' ').trim()
114
115/**
116 * `resume`: whether the companion has a thread to continue. `--resume-last` with none fails at once
117 * ("No previous Codex task thread was found"), so a repository's first Codex job, and an independent
118 * review (`fresh`), start a new thread with `--fresh`.
119 */
120export function codexArgs(root: string, prompt: string, config: Pick<ExecutorConfig, 'companionScript'>, opts: DispatchOptions, resume = true): string[] {
121 if (!config.companionScript) throw new Error('Codex executor requires companionScript.')
122 return [
123 'node', config.companionScript, 'task', '--background', '--write', opts.fresh || !resume ? '--fresh' : '--resume-last', '--cwd', slash(root), '--json',
124 ...(opts.model ? ['--model', opts.model] : []),
125 ...(opts.effort ? ['--effort', opts.effort] : []),
126 prompt,
127 ]
128}
129
130export function claudeArgs(_root: string, prompt: string, opts: DispatchOptions, sessionId?: string, launchName?: string): string[] {
131 return [
132 'claude', '--bg',
133 ...(launchName ? ['--name', launchName] : []),
134 ...(sessionId ? ['--resume', sessionId] : []),
135 ...(opts.model ? ['--model', opts.model] : []),
136 ...(opts.effort ? ['--effort', opts.effort] : []),
137 prompt,
138 ]
139}
140
141function lastMeaningfulLine(text: string): string {
142 const lines = terminalLines(text).map(line => line.trim()).filter(line => line && !/^[-=─*`]+$/.test(line))
143 return (lines.pop() ?? '').replace(/^\[[^\]]*\]\s*/, '').slice(0, 80)
144}
145
146function parseCompanionId(stdout: string): string | null {
147 try {
148 const value = JSON.parse(stdout.trim())
149 return typeof value?.jobId === 'string' && value.jobId.trim() ? value.jobId.trim() : null
150 } catch { return null }
151}
152
153function parseLaunchToken(stdout: string): string | null {
154 const text = cleanLine(stdout)
155 if (SHORT_ID.test(text) || UUID.test(text)) return text
156 try {
157 const value = JSON.parse(text)
158 const candidate = typeof value?.id === 'string' ? value.id.trim() : typeof value?.sessionId === 'string' ? value.sessionId.trim() : ''
159 return SHORT_ID.test(candidate) || UUID.test(candidate) ? candidate : null
160 } catch { /* Plain native output follows. */ }
161 const found = new Set<string>()
162 for (const match of text.matchAll(/(?<![0-9a-f])(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{8})(?![0-9a-f])/gi)) found.add(match[0])
163 return found.size === 1 ? [...found][0] : null
164}
165
166function emptyState(): ClaudeState { return { version: 1, roots: {} } }
167
168async function readState(deps: ExecutorDeps, path: string): Promise<ClaudeState> {
169 let text: string
170 try { text = await deps.files.read(path) } catch (error) {
171 const code = error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code) : ''
172 const message = String(error)
173 if (code === 'ENOENT' || /\bENOENT\b|no such file/i.test(message)) return emptyState()
174 throw new Error(`Claude sessions file could not be read: ${message}`)
175 }
176 try {
177 const value = JSON.parse(text.replace(/^\uFEFF/, ''))
178 if (!value || value.version !== 1 || typeof value !== 'object' || !value.roots || typeof value.roots !== 'object' || Array.isArray(value.roots)) throw new Error('invalid shape')
179 const roots: Record<string, RootState> = {}
180 for (const [key, raw] of Object.entries(value.roots as Record<string, unknown>)) {
181 if (!raw || typeof raw !== 'object') throw new Error('invalid root')
182 const source = raw as Record<string, unknown>
183 if (typeof source.root !== 'string' || rootKey(source.root) !== rootKey(key) || !Array.isArray(source.jobs)) throw new Error('invalid root')
184 if (source.sessionId !== undefined && (typeof source.sessionId !== 'string' || !UUID.test(source.sessionId))) throw new Error('invalid session id')
185 const jobs: ClaudeJob[] = []
186 for (const entry of source.jobs) {
187 if (!entry || typeof entry !== 'object') throw new Error('invalid job')
188 const job = entry as Record<string, unknown>
189 if (typeof job.id !== 'string' || typeof job.root !== 'string' || rootKey(job.root) !== rootKey(key) || typeof job.prompt !== 'string' || typeof job.startedAt !== 'string') throw new Error('invalid job')
190 jobs.push({
191 id: job.id, root: job.root, prompt: job.prompt, startedAt: job.startedAt,
192 ...(job.kind === 'sync' || job.kind === 'continue' ? { kind: job.kind } : {}),
193 ...(job.fresh === true ? { fresh: true } : {}),
194 status: typeof job.status === 'string' ? job.status : 'running',
195 phase: typeof job.phase === 'string' ? job.phase : 'unknown',
196 ...(typeof job.sessionId === 'string' ? { sessionId: job.sessionId } : {}),
197 ...(typeof job.unmanagedSessionId === 'string' ? { unmanagedSessionId: job.unmanagedSessionId } : {}),
198 ...(typeof job.warning === 'string' ? { warning: job.warning } : {}),
199 ...(typeof job.nativeId === 'string' ? { nativeId: job.nativeId } : {}),
200 ...(typeof job.launchName === 'string' ? { launchName: job.launchName } : {}),
201 ...(job.fallbackFrom === 'codex' ? { fallbackFrom: 'codex' as const } : {}),
202 ...(typeof job.fallbackReason === 'string' ? { fallbackReason: job.fallbackReason } : {}),
203 ...(typeof job.model === 'string' ? { model: job.model } : {}),
204 ...(typeof job.effort === 'string' ? { effort: job.effort } : {}),
205 ...(typeof job.updatedAt === 'string' ? { updatedAt: job.updatedAt } : {}),
206 ...(typeof job.completedAt === 'string' ? { completedAt: job.completedAt } : {}),
207 })
208 }
209 roots[rootKey(key)] = {
210 root: slash(source.root), jobs: jobs.slice(-MAX_HISTORY),
211 ...(typeof source.sessionId === 'string' && UUID.test(source.sessionId) ? { sessionId: source.sessionId } : {}),
212 }
213 }
214 return { version: 1, roots }
215 } catch { throw new Error('Claude sessions file is malformed; refusing to dispatch without its saved session id.') }
216}
217
218async function locked<T>(path: string, work: () => Promise<T>): Promise<T> {
219 const previous = locks.get(path) ?? Promise.resolve()
220 let release = () => {}
221 const current = new Promise<void>(resolve => { release = resolve })
222 locks.set(path, previous.then(() => current))
223 await previous
224 try { return await work() } finally { release() }
225}
226
227async function mutateState<T>(deps: ExecutorDeps, path: string, mutate: (state: ClaudeState) => { value: T; changed: boolean }): Promise<T> {
228 return locked(path, async () => {
229 const state = await readState(deps, path)
230 const before = JSON.stringify(state)
231 const { value, changed } = mutate(state)
232 if (changed) {
233 for (const root of Object.values(state.roots)) root.jobs = root.jobs.slice(-MAX_HISTORY)
234 if (JSON.stringify(await readState(deps, path)) !== before) throw new Error('Claude sessions file changed during update; refresh before retrying.')
235 const text = JSON.stringify(state, null, 2) + '\n'
236 await deps.files.write(path, text)
237 if (await deps.files.read(path) !== text) throw new Error('Claude sessions file verification failed after writing.')
238 }
239 return value
240 })
241}
242
243/** One `claude agents --json --all` for every caller until the returned function is dropped. */
244export function sharedAgents(run: ExecutorDeps['run']): () => Promise<Agent[]> {
245 let pending: Promise<Agent[]> | null = null
246 return () => pending ??= run(['claude', 'agents', '--json', '--all'], { timeoutMs: 60_000 }).then(result => {
247 if (result.exitCode !== 0) throw new Error(lastMeaningfulLine(`${result.stdout}\n${result.stderr}`) || `Claude agents failed (exit ${result.exitCode}).`)
248 return parseAgents(result.stdout)
249 })
250}
251
252function parseAgents(stdout: string): Agent[] {
253 let raw: unknown
254 try { raw = JSON.parse(stdout.replace(/^\uFEFF/, '')) } catch { throw new Error('Claude agents returned invalid JSON.') }
255 const values = Array.isArray(raw) ? raw : raw && typeof raw === 'object' && Array.isArray((raw as { agents?: unknown }).agents) ? (raw as { agents: unknown[] }).agents : null
256 if (!values) throw new Error('Claude agents returned an unexpected shape.')
257 return values.filter((value): value is Agent => !!value && typeof value === 'object')
258}
259
260async function queryAgents(deps: ExecutorDeps, root: string): Promise<Agent[]> {
261 const normalized = slash(root)
262 if (deps.agents) return (await deps.agents()).filter(agent => typeof agent.cwd === 'string' && belongsTo(agent.cwd, normalized))
263 const result = await deps.run(['claude', 'agents', '--json', '--all', '--cwd', normalized], { cwd: normalized, timeoutMs: 60_000 })
264 if (result.exitCode !== 0) throw new Error(lastMeaningfulLine(`${result.stdout}\n${result.stderr}`) || `Claude agents failed (exit ${result.exitCode}).`)
265 return parseAgents(result.stdout).filter(agent => typeof agent.cwd === 'string' && belongsTo(agent.cwd, normalized))
266}
267
268/** The project root itself, or an isolated worktree Claude Code created inside it. */
269function belongsTo(cwd: string, root: string): boolean {
270 const dir = rootKey(cwd)
271 const base = rootKey(root)
272 return dir === base || dir.startsWith(`${base}/.claude/worktrees/`)
273}
274
275function phaseOf(agent: Agent): { status: string; phase: string; done: boolean; terminal: boolean } {
276 const state = (agent.state ?? '').toLowerCase()
277 const status = (agent.status ?? '').toLowerCase()
278 if (['failed', 'error'].includes(state) || ['failed', 'error'].includes(status)) return { status: 'failed', phase: state || status, done: false, terminal: true }
279 if (['stopped', 'cancelled', 'canceled', 'killed'].includes(state) || ['cancelled', 'canceled', 'killed'].includes(status)) return { status: 'cancelled', phase: state || status, done: false, terminal: true }
280 if (state === 'done') return { status: 'completed', phase: 'done', done: true, terminal: true }
281 if (state === 'blocked') return { status: 'running', phase: `blocked${agent.waitingFor ? `: ${agent.waitingFor}` : ''}`, done: false, terminal: false }
282 if (status === 'waiting') return { status: 'running', phase: `waiting${agent.waitingFor ? `: ${agent.waitingFor}` : ''}`, done: false, terminal: false }
283 if (status === 'exited') return { status: 'failed', phase: 'exited', done: false, terminal: true }
284 return { status: 'running', phase: state || status || 'unknown', done: false, terminal: false }
285}
286
287const IDLE_GRACE_MS = 60_000
288
289/**
290 * A session the daemon retired (`idle-prompt`) stays in `claude agents` with its last state but
291 * without `pid` or `status`. A live agent always reports a status; a listing that reports neither
292 * for one still `working` is an older CLI, which is left as it was.
293 */
294function retiredAgent(agent: Agent): boolean {
295 const state = (agent.state ?? '').toLowerCase()
296 return (agent.pid === undefined || agent.pid === null) && !agent.status && (state === 'blocked' || state === 'idle')
297}
298
299/**
300 * The original session of a `--resume` is at work, so a new session that comes back is a copy beside it: listed,
301 * not retired, not finished and not idle (busy, working or at a permission prompt). One that ended its turn,
302 * even on a question to the user with its process still up, is waiting for this very resume: the new session takes over.
303 */
304function sessionAtWork(agents: Agent[], sessionId: string): boolean {
305 return agents.some(agent => (agent.kind === undefined || agent.kind === 'background') && typeof agent.sessionId === 'string'
306 && agent.sessionId.toLowerCase() === sessionId.toLowerCase() && !retiredAgent(agent) && !phaseOf(agent).terminal
307 && (agent.status ?? '').toLowerCase() !== 'idle')
308}
309
310/**
311 * An agent that finished its turn reports `status: idle` while `state` can still say `working`, or
312 * `blocked` when its final message asks the user something. Past a short grace after launch either
313 * is a finished job. A permission prompt mid-turn reports `status: waiting` and stays running.
314 */
315function settledPhase(agent: Agent, now: number): ReturnType<typeof phaseOf> {
316 const next = phaseOf(agent)
317 // The daemon retired it: listed with its last state, but no process is left to run anything.
318 if (retiredAgent(agent)) return { status: 'completed', phase: (agent.state ?? '').toLowerCase() === 'blocked' ? 'idle: 等你回覆' : 'idle', done: true, terminal: true }
319 if (next.status !== 'running' || (agent.status ?? '').toLowerCase() !== 'idle') return next
320 const started = typeof agent.startedAt === 'number' ? agent.startedAt : Date.parse(String(agent.startedAt ?? ''))
321 if (Number.isFinite(started) && now - started < IDLE_GRACE_MS) return next
322 const asks = (agent.state ?? '').toLowerCase() === 'blocked'
323 return { status: 'completed', phase: asks ? 'idle: 等你回覆' : 'idle', done: true, terminal: true }
324}
325
326function matchAgent(job: ClaudeJob, agents: Agent[], latest: boolean): Agent | null {
327 const exactName = job.launchName ? agents.filter(agent => agent.name === job.launchName) : []
328 if (exactName.length === 1) return exactName[0]
329 if (exactName.length > 1) return null
330 // A copy's original session id belongs to a different turn, never to the copy.
331 if (job.unmanagedSessionId) return null
332 // Pending resumes may share both native and session ids with the previous turn.
333 // Only the unique name assigned to this launch can resolve them.
334 if (job.phase === 'starting' || job.phase === 'unknown') return null
335 const exactLaunch = job.nativeId ? agents.filter(agent => agent.id === job.nativeId || agent.name === job.nativeId) : []
336 if (exactLaunch.length === 1) return exactLaunch[0]
337 if (exactLaunch.length > 1) return null
338 if (!latest || job.status !== 'running' || !job.sessionId) return null
339 const exactSession = agents.filter(agent => agent.sessionId === job.sessionId)
340 return exactSession.length === 1 ? exactSession[0] : null
341}
342
343// A `--bg --resume` copy made while the original session is at work may already be running the prompt:
344// it stays an active job (tracked by its unique launch name, so it keeps blocking dispatch) but never
345// replaces the project's session. When the original is idle, retired or gone, the new session simply takes its place.
346function noteCopy(job: ClaudeJob, agent: Agent): string {
347 job.unmanagedSessionId = agent.sessionId
348 job.warning = `Claude resume created an unmanaged copy (${agent.sessionId}); original session ${job.sessionId} preserved. The copy blocks new dispatch until it finishes.`
349 return job.warning
350}
351
352function reconcile(root: RootState, agents: Agent[], nowIso: string): boolean {
353 let changed = false
354 for (let index = 0; index < root.jobs.length; index++) {
355 const job = root.jobs[index]
356 // A new turn may reuse the native session id; completed launches are immutable history.
357 if (['completed', 'failed', 'cancelled'].includes(job.status)) continue
358 const agent = matchAgent(job, agents, index === root.jobs.length - 1)
359 if (!agent) continue
360 const sessionId = typeof agent.sessionId === 'string' && UUID.test(agent.sessionId) ? agent.sessionId : undefined
361 // Known copies matched by their unique launch name can report status without repeating the UUID.
362 if (!sessionId && !job.unmanagedSessionId) continue
363 const isCopy = !!job.unmanagedSessionId || (!!job.sessionId && !!sessionId && job.sessionId.toLowerCase() !== sessionId.toLowerCase() && sessionAtWork(agents, job.sessionId))
364 if (isCopy && sessionId && job.unmanagedSessionId !== sessionId) { noteCopy(job, agent); changed = true }
365 if (typeof agent.id === 'string' && agent.id && job.nativeId !== agent.id) { job.nativeId = agent.id; changed = true }
366 if (!isCopy && sessionId) {
367 if (job.sessionId !== sessionId) { job.sessionId = sessionId; changed = true }
368 if (!job.fresh && index === root.jobs.length - 1 && root.sessionId !== job.sessionId) { root.sessionId = job.sessionId; changed = true }
369 }
370 const next = settledPhase(agent, Date.parse(nowIso))
371 if (job.status !== next.status) { job.status = next.status; changed = true }
372 if (job.phase !== next.phase) { job.phase = next.phase; changed = true }
373 if (job.updatedAt !== nowIso) { job.updatedAt = nowIso; changed = true }
374 if (next.terminal && !job.completedAt) { job.completedAt = nowIso; changed = true }
375 }
376 return changed
377}
378
379function asJob(value: ClaudeJob): ExecutorJob {
380 return {
381 id: value.id, kind: value.kind, executor: 'claude', jobClass: 'task', status: value.status, summary: value.prompt,
382 nativeId: value.nativeId, sessionId: value.sessionId,
383 ...(value.fallbackFrom ? { fallbackFrom: value.fallbackFrom, fallbackReason: value.fallbackReason } : {}),
384 unmanagedSessionId: value.unmanagedSessionId, warning: value.warning,
385 createdAt: value.startedAt, startedAt: value.startedAt, updatedAt: value.updatedAt, completedAt: value.completedAt,
386 phase: value.phase, request: { prompt: value.prompt, model: value.model, effort: value.effort },
387 }
388}
389
390/**
391 * Asks the companion whether this repository has a Codex thread to resume. Only a clear
392 * `available: false` starts a new thread; a probe that fails or says nothing keeps `--resume-last`.
393 */
394export async function codexHasThread(deps: Pick<ExecutorDeps, 'run'>, config: Pick<ExecutorConfig, 'companionScript'>, root: string): Promise<boolean> {
395 if (!config.companionScript) return true
396 const probe = await deps.run(['node', config.companionScript, 'task-resume-candidate', '--cwd', root, '--json'], { cwd: root, timeoutMs: 15_000 }).catch(() => null)
397 if (!probe || probe.exitCode !== 0) return true
398 try {
399 return (JSON.parse(probe.stdout) as { available?: unknown }).available !== false
400 } catch {
401 return true
402 }
403}
404
405function createCodex(deps: ExecutorDeps, config: ExecutorConfig): Executor {
406 return {
407 async listJobs(root) {
408 const jobs = await readJobs(deps.files, config.companionStateRoots, slash(root))
409 return jobs.map(job => {
410 const kind = job.kind ?? dispatchKind(job.request?.prompt)
411 return { ...job, executor: 'codex' as const, ...(kind ? { kind } : {}) }
412 })
413 },
414 async dispatch(root, prompt, opts) {
415 const normalized = slash(root)
416 const resume = opts.fresh ? false : await codexHasThread(deps, config, normalized)
417 const result = await deps.run(codexArgs(normalized, prompt, config, opts, resume), { cwd: normalized, timeoutMs: 60_000 })
418 if (result.exitCode !== 0) throw new Error(lastMeaningfulLine(`${result.stdout}\n${result.stderr}`) || `Codex dispatch failed (exit ${result.exitCode}).`)
419 const id = parseCompanionId(result.stdout)
420 if (!id) throw new Error('Codex dispatch did not return a job id.')
421 const at = iso(await deps.now())
422 return { id, executor: 'codex', jobClass: 'task', status: 'queued', summary: prompt, createdAt: at, startedAt: at, phase: 'queued', request: { prompt, model: opts.model, effort: opts.effort } }
423 },
424 async lastLine(job) {
425 if (!job.logFile) return ''
426 return logTail(deps, job.logFile)
427 },
428 }
429}
430
431function createClaude(deps: ExecutorDeps, config: ExecutorConfig): Executor {
432 const path = config.claudeSessionsPath
433 return {
434 async listJobs(rawRoot) {
435 const rootName = slash(rawRoot)
436 const key = rootKey(rootName)
437 if (!(await readState(deps, path)).roots[key]) return []
438 const agents = await queryAgents(deps, rootName)
439 const nowIso = iso(await deps.now())
440 return mutateState(deps, path, state => {
441 const root = state.roots[key]
442 if (!root) return { value: [] as ExecutorJob[], changed: false }
443 const changed = reconcile(root, agents.filter(agent => agent.kind === 'background'), nowIso)
444 return { value: root.jobs.map(asJob), changed }
445 })
446 },
447 async dispatch(rawRoot, prompt, opts) {
448 const rootName = slash(rawRoot)
449 const key = rootKey(rootName)
450 const agents = await queryAgents(deps, rootName)
451 const now = await deps.now()
452 const nowIso = iso(now)
453 const launch = await mutateState(deps, path, state => {
454 const root = state.roots[key] ?? { root: rootName, jobs: [] }
455 state.roots[key] = root
456 reconcile(root, agents.filter(agent => agent.kind === 'background'), nowIso)
457 const unresolved = root.jobs.some(job => job.phase === 'starting' || job.phase === 'unknown')
458 if (unresolved) throw new Error('Claude dispatch has an unresolved launch; refresh it before retrying.')
459 const ownedActive = !!root.sessionId && agents.some(agent => agent.sessionId === root.sessionId && settledPhase(agent, now).status === 'running')
460 if (ownedActive) throw new Error('Claude session is already active; wait for it before dispatching again.')
461 if (root.jobs.some(isActiveJob)) throw new Error('Claude workspace has an active job; wait for it before dispatching again.')
462 const suffix = root.jobs.length.toString(36)
463 const pendingId = `starting:${now}:${suffix}`
464 const launchName = `console-${now.toString(36)}-${suffix}`
465 const sessionId = opts.fresh ? undefined : opts.resumeSession ?? root.sessionId
466 // Resuming a session other than the project's (a review that stopped to ask) keeps the project where it is.
467 const detached = !!opts.fresh || (!!sessionId && !!root.sessionId && sessionId.toLowerCase() !== root.sessionId.toLowerCase())
468 root.jobs.push({
469 id: pendingId, kind: opts.kind ?? 'continue', launchName, sessionId, root: rootName, prompt, model: opts.model, effort: opts.effort, startedAt: nowIso, status: 'running', phase: 'starting',
470 ...(detached ? { fresh: true } : {}),
471 ...(opts.fallbackFrom ? { fallbackFrom: opts.fallbackFrom, ...(opts.fallbackReason ? { fallbackReason: opts.fallbackReason } : {}) } : {}),
472 })
473 return { value: { sessionId, pendingId, launchName }, changed: true }
474 })
475
476 let result: RunResult
477 try {
478 result = await deps.run(claudeArgs(rootName, prompt, opts, launch.sessionId, launch.launchName), { cwd: rootName, timeoutMs: 60_000 })
479 } catch (error) {
480 await markUnknown(deps, path, key, launch.pendingId)
481 throw error
482 }
483 if (result.exitCode !== 0) {
484 try {
485 const observed = await queryAgents(deps, rootName)
486 const exists = observed.some(agent => agent.kind === 'background' && agent.name === launch.launchName)
487 if (exists) await markUnknown(deps, path, key, launch.pendingId)
488 else await markTerminal(deps, path, key, launch.pendingId, 'failed')
489 } catch { await markUnknown(deps, path, key, launch.pendingId) }
490 throw new Error(lastMeaningfulLine(`${result.stdout}\n${result.stderr}`) || `Claude dispatch failed (exit ${result.exitCode}).`)
491 }
492 const token = parseLaunchToken(result.stdout)
493 if (!token) {
494 await markUnknown(deps, path, key, launch.pendingId)
495 throw new Error('Claude dispatch returned an unknown background id.')
496 }
497 const after = await queryAgents(deps, rootName)
498 const beforeIds = new Set(agents.map(agent => `${agent.id ?? ''}|${agent.name ?? ''}|${agent.sessionId ?? ''}`))
499 const matches = after.filter(agent => agent.kind === 'background' && agent.name === launch.launchName && (agent.id === token || agent.sessionId === token || agent.name === token) && !beforeIds.has(`${agent.id ?? ''}|${agent.name ?? ''}|${agent.sessionId ?? ''}`))
500 const confirmed = matches.length === 1 && typeof matches[0].sessionId === 'string' && UUID.test(matches[0].sessionId) ? matches[0] : null
501 if (!confirmed) {
502 await markUnknown(deps, path, key, launch.pendingId)
503 throw new Error('Claude launch could not uniquely confirm its full session id.')
504 }
505 if (launch.sessionId && launch.sessionId.toLowerCase() !== confirmed.sessionId!.toLowerCase() && sessionAtWork(after, launch.sessionId)) {
506 const warning = await mutateState(deps, path, state => {
507 const job = state.roots[key]?.jobs.find(item => item.id === launch.pendingId)
508 if (!job) throw new Error('Claude launch metadata was lost before confirmation.')
509 job.id = `${launch.launchName}:${token}`
510 job.nativeId = token
511 const next = phaseOf(confirmed)
512 job.status = next.status
513 job.phase = next.phase
514 job.updatedAt = nowIso
515 if (next.terminal) job.completedAt = nowIso
516 return { value: noteCopy(job, confirmed), changed: true }
517 })
518 throw new Error(warning)
519 }
520 return mutateState(deps, path, state => {
521 const root = state.roots[key]
522 const job = root?.jobs.find(item => item.id === launch.pendingId)
523 if (!root || !job) throw new Error('Claude launch metadata was lost before confirmation.')
524 job.id = `${launch.launchName}:${token}`
525 job.nativeId = token
526 job.sessionId = confirmed.sessionId
527 if (!job.fresh) root.sessionId = confirmed.sessionId
528 const next = phaseOf(confirmed)
529 job.status = next.status
530 job.phase = next.phase
531 job.updatedAt = nowIso
532 if (next.terminal) job.completedAt = nowIso
533 return { value: asJob(job), changed: true }
534 })
535 },
536 async lastLine(job) {
537 const nativeId = (job as ExecutorJob).nativeId
538 if (!nativeId) return ''
539 const result = await deps.run(['claude', 'logs', nativeId], { timeoutMs: 60_000 })
540 if (result.exitCode !== 0) throw new Error(lastMeaningfulLine(`${result.stdout}\n${result.stderr}`) || `Claude logs failed (exit ${result.exitCode}).`)
541 return lastMeaningfulLine(result.stdout)
542 },
543 }
544}
545
546async function markUnknown(deps: ExecutorDeps, path: string, key: string, pendingId: string): Promise<void> {
547 await mutateState(deps, path, state => {
548 const job = state.roots[key]?.jobs.find(item => item.id === pendingId)
549 if (!job) return { value: undefined, changed: false }
550 job.phase = 'unknown'
551 job.status = 'running'
552 return { value: undefined, changed: true }
553 })
554}
555
556async function markTerminal(deps: ExecutorDeps, path: string, key: string, pendingId: string, phase: string): Promise<void> {
557 const at = iso(await deps.now())
558 await mutateState(deps, path, state => {
559 const job = state.roots[key]?.jobs.find(item => item.id === pendingId)
560 if (!job) return { value: undefined, changed: false }
561 job.phase = phase
562 job.status = 'failed'
563 job.updatedAt = at
564 job.completedAt = at
565 return { value: undefined, changed: true }
566 })
567}
568
569export function createExecutor(kind: ExecutorKind, deps: ExecutorDeps, config: ExecutorConfig): Executor {
570 return kind === 'codex' ? createCodex(deps, config) : createClaude(deps, config)
571}
572
573/**
574 * Read both executors and merge them: a project can hold jobs from either one (per-project executor
575 * changes, Claude fallback for a Codex dispatch), and RUNNING detection must see all of them.
576 */
577export async function listWorkspaceJobs(kind: ExecutorKind, deps: ExecutorDeps, config: ExecutorConfig, root: string): Promise<ExecutorJob[]> {
578 const other = kind === 'claude' ? 'codex' : 'claude'
579 const [selected, background] = await Promise.all([
580 createExecutor(kind, deps, config).listJobs(root),
581 createExecutor(other, deps, config).listJobs(root),
582 ])
583 return [...selected, ...background]
584}
585hooks/actions.ts 267 lines1import type { Project, Snapshot, ActionKind, ContinueConfirmation, VerificationResult, PendingAction, ReviewRequest } from '../types'
2import { terminalLines } from './terminal'
3import type { State } from './logic'
4import { hasAsk, parseGate, rows, saysNone } from './logic'
5
6export const ACTION_LABEL: Record<ActionKind, string> = {
7 verify: '▶ 執行驗證', sync: '⇢ 同步 STATUS', continue: '⇢ 繼續下一步', reply: '↩ 回覆執行者',
8 decide: '✎ 做決定', gate: '⚑ 審核關卡', open: '↗ 開啟 STATUS.md',
9}
10
11export function actionLabel(kind: ActionKind, project: Project): string {
12 return kind === 'gate' && parseGate(project.gate)?.kind === 'release' ? '⚑ 最終審核' : ACTION_LABEL[kind]
13}
14
15export function dispatchBlockReason(project: Project): string {
16 const active = project.jobs.filter(job => job.kind === 'running')
17 return active.length ? `${[...new Set(active.map(job => job.executor ?? '執行者'))].join(' / ')} 工作尚未結束` : ''
18}
19
20/** A `manual` project is handoff-only: the panel never dispatches it, CARD/verify/gates still work. */
21export const isManual = (project: Project) => project.executor === 'manual'
22
23export function actionKinds(project: Project, state: State): ActionKind[] {
24 const kinds: ActionKind[] = []
25 const dispatchable = !isManual(project)
26 if (project.verify.trim()) kinds.push('verify')
27 // A finished job that stopped to ask the user is an ACTION row; syncing its result is still the way on.
28 const asking = state === 'ACTION' && !hasAsk(project) && project.jobs.some(job => job.kind === 'newer' && job.asks)
29 // The asking session can be answered in place: the reply resumes it with the person's words as its prompt.
30 if (dispatchable && asking && askingSession(project) && !dispatchBlockReason(project)) kinds.push('reply')
31 if (dispatchable && (state === 'SYNC' || asking) && !dispatchBlockReason(project)) kinds.push('sync')
32 const gate = parseGate(project.gate)
33 const next = project.next.trim()
34 if (dispatchable && state === 'IDLE' && !dispatchBlockReason(project) && next && !saysNone(next) && !hasAsk(project) && !gate) kinds.push('continue')
35 if (hasAsk(project)) kinds.push('decide')
36 if (gate && gate.kind !== 'unknown') kinds.push('gate')
37 return [...kinds, 'open']
38}
39
40/** A submitted review may wait this long for a turn to finish it before the row unlocks by itself. */
41export const GATE_PENDING_MS = 10 * 60_000
42
43/**
44 * Whether a turn's text is the review a request submitted. The host may wrap a plugin's prompt
45 * (`The console-status plugin sent a message:` above it, a note below it), so the prompt's first
46 * line found inside the turn's text counts as much as the exact text.
47 */
48export function reviewTurnMatches(request: { text: string }, turnText: string): boolean {
49 if (turnText === request.text) return true
50 const marker = request.text.split(/\r?\n/).map(line => line.trim()).find(line => line) ?? ''
51 return marker.length >= 12 && turnText.includes(marker)
52}
53
54/** A pending review whose CARD no longer carries a gate: over, as far as the row is concerned. */
55export function staleGate(pending: PendingAction | undefined, project: Project): boolean {
56 return !!pending && pending.kind === 'gate' && !parseGate(project.gate)
57}
58
59const sameGate = (current: string | undefined, submitted: string | undefined) => submitted === undefined || (current ?? '').trim() === submitted.trim()
60
61/**
62 * Why a pending action should be dropped, or null while someone still owns it: a review whose gate the CARD
63 * moved past, or that waited GATE_PENDING_MS with no turn running for it; any other action nobody in this
64 * plugin lifetime holds the lock for, which only an earlier lifetime (before a reload) can have left behind.
65 */
66export function stalePendingReason(pending: PendingAction, request: ReviewRequest | undefined, project: Project | undefined, now: number, locked: boolean, turnRunning: boolean): string | null {
67 if (pending.kind !== 'gate') return locked ? null : '動作已失去追蹤'
68 if (!request) return locked ? null : '審核已失去追蹤'
69 if (project && !sameGate(project.gate, request.gate)) return '關卡已變更'
70 if (request.turnId && turnRunning) return null
71 if (now - pending.at >= GATE_PENDING_MS) return '審核狀態已逾時'
72 return null
73}
74
75const SYNC_INSTRUCTION = '把最近完成的工作結果寫回 STATUS CARD,只改 CARD 與歷程,不做其他變更'
76const CONTINUE_INSTRUCTION = '依 STATUS CARD 的下一步繼續;遵守任務骨架;結束時更新 CARD(含關卡欄)'
77
78/** What a job was dispatched for, read back from its prompt (Codex's state file keeps the prompt, not our kind). */
79export function dispatchKind(prompt: string | undefined): 'sync' | 'continue' | undefined {
80 const text = (prompt ?? '').trimStart()
81 return text.startsWith(SYNC_INSTRUCTION) ? 'sync' : text.startsWith(CONTINUE_INSTRUCTION) ? 'continue' : undefined
82}
83
84/**
85 * How a continue turn starts and ends, so the session stays in step with the console: the rev that counts is
86 * the one read now (a resumed session remembers older ones), a review gate waits for a finished review, and a
87 * turn that reaches a gate or a decision ends instead of waiting on a question nobody is attached to see.
88 */
89const REV_RULE = '開始前先重讀 CARD,以這次讀到的 rev 為準;寫入前再重讀一次,只有這兩次不同才停下回報(不要跟記憶裡更早的 rev 比)。'
90/** 更新 is how the console knows the CARD was written: a guessed or UTC time can read as older than the job. */
91const TIME_RULE = '更新欄寫現在的本機時間:先執行 date(Windows 用 Get-Date)取得,不要猜;格式 YYYY-MM-DD HH:MM · rev <n+1> · job <這次的工作 id,若知道>。'
92/**
93 * A sync resumes the project's session, which remembers an older rev: without the rule it saw a "conflict" and
94 * stopped without writing. With nothing new to record it still writes, so the console sees the sync land.
95 */
96const SYNC_RULES = [
97 REV_RULE,
98 TIME_RULE,
99 '沒有新結果也要寫回:更新時間與 rev,並在歷程記一行「同步:沒有新結果」。',
100]
101const CONTINUE_RULES = [
102 REV_RULE,
103 TIME_RULE,
104 '驗收綠、獨立審核還沒跑完:關卡留 無,下一步寫審核任務;審核跑完才設 review。',
105 '審核任務的下一步以 .task/review-<name>.md 路徑開頭(才會開新 session);修正工作以動詞開頭(例如「依 .task/review-x.md 的意見修正」)。',
106 '到關卡或需要使用者決定時,把它寫進 CARD(等使用者/關卡)後結束這一輪,不要提問等待。',
107]
108
109export function dispatchPrompt(project: Project, kind: 'sync' | 'continue'): string {
110 const instruction = kind === 'sync' ? SYNC_INSTRUCTION : CONTINUE_INSTRUCTION
111 const rules = `\n${(kind === 'continue' ? CONTINUE_RULES : SYNC_RULES).join('\n')}`
112 return `${instruction}\nSTATUS:${project.statusPath}\n只在此專案授權的本機範圍作業。不得執行正式環境變更或 release;需要上線時填入 release 關卡,交主控台整理後由使用者決定。${rules}`
113}
114
115/**
116 * A sync in a new small session (`sync.session: fresh`, the default once a sync model is set) has no memory of
117 * the work: it learns what finished from the files, git and the executor digest that follows, and writes only what they show.
118 */
119const FRESH_SYNC_RULES = [
120 '這是新的 session,沒有先前的對話:先讀 STATUS.md、git log 與 git status,再看下面的執行者摘要,判斷最近完成了什麼。',
121 '只寫檔案、git 與摘要看得到的事實;看不出結果的項目寫「待確認」,不要推測或補做工作。',
122]
123
124/** A sync dispatched to a new session: the usual sync prompt, how to find the work without memory, and the digest. */
125export function freshSyncPrompt(project: Project, digest: string): string {
126 return `${dispatchPrompt(project, 'sync')}\n${FRESH_SYNC_RULES.join('\n')}\n${digest}`
127}
128
129/** A reply to an executor that stopped to ask: the person's words first, then the same rules a continue carries. */
130export function replyPrompt(project: Project, text: string): string {
131 return `使用者在主控台回覆你上一輪的提問:\n${text.trim()}\n\n依這個回覆繼續;遵守任務骨架;結束時更新 STATUS CARD(含關卡欄)。\nSTATUS:${project.statusPath}\n只在此專案授權的本機範圍作業。不得執行正式環境變更或 release;需要上線時填入 release 關卡,交主控台整理後由使用者決定。\n${CONTINUE_RULES.join('\n')}`
132}
133
134export function gatePrompt(project: Project): string {
135 const gate = parseGate(project.gate)
136 if (!gate || gate.kind === 'unknown') throw new Error('關卡種類無法辨識;請先檢查 STATUS。')
137 const base = `依 claude-console skill 審核「${project.name}」的 ${gate.kind} 關卡。`
138 return gate.kind === 'release'
139 ? `${base}整理成「可上線/不可上線+理由+要使用者確認的一句」;不得自行執行 release 或任何正式環境變更。`
140 : `${base}依證據判斷,指出結果與理由,更新關卡結論;不得執行 release 或正式環境變更。`
141}
142
143/**
144 * An independent review starts in a new session: it must not carry the work's context. A review's 下一步 starts
145 * with its task path (`.task/review-x.md(…)`); work that follows a review starts with a verb
146 * (`依 .task/review-x.md 的意見修正`) and resumes the project's session.
147 */
148export function freshSession(project: Project): boolean {
149 return /^`?\.task[\\/]+review-[^\s))`]*\.md/i.test(project.next.trim())
150}
151
152/**
153 * An independent review judges from its task file, the acceptance output and the diff. It gets no executor digest:
154 * the work session's own last words ("tests all pass") would anchor the verdict before the review has looked.
155 */
156const REVIEW_RULE = '這是獨立審核,在新的 session/thread 執行:只依審核任務檔、驗收指令的實際輸出與 diff 判斷,不參考執行者自己的結論;自己重跑驗收,不要沿用 CARD 或報告裡寫的結果。'
157
158/** A continue whose 下一步 is a review task: the continue prompt with the review rule, and no executor digest. */
159export function reviewPrompt(project: Project): string {
160 return `${dispatchPrompt(project, 'continue')}\n${REVIEW_RULE}`
161}
162
163/** The session of the newest finished job that stopped to ask the user: the one its row's sync must resume. */
164export function askingSession(project: Project): string | undefined {
165 return project.jobs.filter(job => job.kind === 'newer' && job.asks && job.sessionId).pop()?.sessionId
166}
167
168export function workSignature(project: Project): string {
169 return JSON.stringify([project.statusPath, project.updated, project.next, project.ask, project.gate ?? ''])
170}
171
172export function confirmationMatches(value: ContinueConfirmation | undefined, signature: string, now: number, windowMs = 3000): boolean {
173 return !!value && value.signature === signature && now >= value.at && now - value.at < windowMs
174}
175
176/**
177 * The CARD's 驗證 command is written by background executors, so a command the user has not
178 * approved for this project (new, or changed since) needs a second press after it is shown in full.
179 */
180export const VERIFY_CONFIRM_MS = 10_000
181/** Long enough to reach the button again on a phone over Remote Control. */
182export const CONTINUE_CONFIRM_MS = 6_000
183export const verifySignature = (command: string) => `verify\u0000${command}`
184export const verifyTrusted = (trusted: Record<string, string>, statusPath: string, command: string) => trusted[statusPath] === command
185
186/** CARD verification is a shell command, run in the project's directory, not the console's. */
187export function verificationArgs(command: string, windows: boolean): string[] {
188 if (!windows) return ['sh', '-lc', command]
189 return ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
190 `$ErrorActionPreference = 'Stop'; & { ${command}\n}; $consoleVerifyOK = $?; if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; if (-not $consoleVerifyOK) { exit 1 }`]
191}
192
193/** The API returns separate streams; append stderr, then keep only the final three nonblank lines. */
194export function outputTail(stdout: string, stderr = ''): string[] {
195 return terminalLines(`${stdout}\n${stderr}`).map(s => s.trimEnd()).filter(s => s.trim()).slice(-3).map(s => s.slice(0, 500))
196}
197
198export function verificationResult(command: string, at: number, result: { exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean; isStderrTruncated?: boolean }): VerificationResult {
199 return { command, at, ok: result.exitCode === 0, exitCode: result.exitCode, lines: outputTail(result.stdout, result.stderr), truncated: !!(result.isStdoutTruncated || result.isStderrTruncated) }
200}
201
202export function launchId(stdout: string): string | null {
203 try {
204 const data = JSON.parse(stdout.trim())
205 return typeof data?.jobId === 'string' && data.jobId.trim() ? data.jobId : null
206 } catch { return null }
207}
208
209/**
210 * What the prompt box offers as its dim Tab suggestion: the way on for the project the next-step card points at
211 * (a decision, a reply to an executor that asked, a review gate, a sync), else continuing the first idle project
212 * with a next step. Each is a prompt or a `/console` command that does exactly that; null when nothing waits.
213 */
214export function suggestedPrompt(s: Snapshot, pending: Record<string, unknown> = {}): string | null {
215 if (s.demo || s.error) return null
216 const list = rows(s)
217 const ready = (name: string) => {
218 const p = s.projects.find(x => x.name === name)
219 return p && !pending[p.statusPath] ? p : undefined
220 }
221 const urgent = list.find(r => r.state === 'ACTION' || r.state === 'GATE') ?? list.find(r => r.state === 'SYNC')
222 if (urgent) {
223 const p = ready(urgent.full)
224 if (!p) return null
225 const kinds = actionKinds(p, urgent.state)
226 if (kinds.includes('decide')) return `「${p.name}」決策:`
227 if (kinds.includes('reply')) return `/console reply ${p.name} `
228 if (kinds.includes('gate')) return `/console gate ${p.name}`
229 if (kinds.includes('sync')) return `/console sync ${p.name}`
230 return null
231 }
232 for (const r of list) {
233 if (r.state !== 'IDLE') continue
234 const p = ready(r.full)
235 if (p && actionKinds(p, 'IDLE').includes('continue')) return `/console continue ${p.name}`
236 }
237 return null
238}
239
240/**
241 * The project a decision prompt names (`「<project>」決策:…`, as ✎ 做決定 and the Tab suggestion write it) when it
242 * has a decision open, so a decision sent without selecting the row still carries that project's context.
243 */
244export function decisionProject<P extends Project>(projects: P[], text: string): P | undefined {
245 const named = text.trimStart().match(/^「([^」]+)」決策:/)?.[1]
246 return named ? projects.find(p => p.name === named && hasAsk(p)) : undefined
247}
248
249/** The actions a `/console <action> <project>` command can start; verify keeps its own second-press check. */
250export const COMMAND_ACTIONS = ['verify', 'sync', 'continue', 'reply', 'gate'] as const
251
252/**
253 * The project a command names and the words after it: the longest project name the text starts with
254 * (names may hold spaces), else the one whose name starts with the first word, ignoring case.
255 */
256export function commandTarget<P extends { name: string }>(projects: P[], rest: string): { project: P; text: string } | null {
257 const text = rest.trim()
258 const lower = text.toLowerCase()
259 const exact = projects.filter(p => lower === p.name.toLowerCase() || lower.startsWith(p.name.toLowerCase() + ' '))
260 .sort((a, b) => b.name.length - a.name.length)[0]
261 if (exact) return { project: exact, text: text.slice(exact.name.length).trim() }
262 const word = text.split(/\s+/)[0] ?? ''
263 if (!word) return null
264 const prefix = projects.filter(p => p.name.toLowerCase().startsWith(word.toLowerCase()))
265 return prefix.length === 1 ? { project: prefix[0]!, text: text.slice(word.length).trim() } : null
266}
267hooks/companion.ts 110 lines1// Locate codex-companion.mjs when the configured path is empty or stale (the Codex plugin
2// installs each version into its own cache folder, so a pinned path goes stale on update).
3
4export type CompanionFiles = {
5 read(path: string): Promise<string>
6 list(path: string): Promise<{ name: string; kind: string }[]>
7}
8export type CompanionSource = 'configured' | 'installed' | 'cache' | 'none'
9export type CompanionResolution = { path: string; source: CompanionSource; version?: string; warning?: string }
10
11const PLUGIN_ID = 'codex@openai-codex'
12const SCRIPT = 'scripts/codex-companion.mjs'
13const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
14const slash = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, '')
15
16/** Semantic-version order (1.0.10 > 1.0.9; a pre-release sorts before its release); null if not semver. */
17export function compareSemver(a: string, b: string): number | null {
18 const x = a.trim().match(SEMVER)
19 const y = b.trim().match(SEMVER)
20 if (!x || !y) return null
21 for (let i = 1; i <= 3; i++) {
22 const d = Number(x[i]) - Number(y[i])
23 if (d) return Math.sign(d)
24 }
25 const pa = x[4], pb = y[4]
26 if (!pa || !pb) return pa === pb ? 0 : pa ? -1 : 1
27 const left = pa.split('.'), right = pb.split('.')
28 for (let i = 0; i < Math.max(left.length, right.length); i++) {
29 const l = left[i], r = right[i]
30 if (l === undefined) return -1
31 if (r === undefined) return 1
32 const ln = /^\d+$/.test(l), rn = /^\d+$/.test(r)
33 if (ln && rn && Number(l) !== Number(r)) return Math.sign(Number(l) - Number(r))
34 if (ln !== rn) return ln ? -1 : 1
35 if (l !== r) return l < r ? -1 : 1
36 }
37 return 0
38}
39
40/** The highest semantic version among names; non-semver names are ignored. */
41export function newestVersion(names: string[]): string | null {
42 let best: string | null = null
43 for (const name of names) {
44 if (compareSemver(name, name) === null) continue
45 if (best === null || (compareSemver(name, best) ?? 0) > 0) best = name
46 }
47 return best
48}
49
50/** Install paths recorded for the Codex plugin, newest version first. */
51export function installedPaths(text: string | null): { path: string; version?: string }[] {
52 try {
53 const data = JSON.parse((text ?? '').replace(/^/, ''))
54 const entries = data?.plugins?.[PLUGIN_ID]
55 const list = Array.isArray(entries) ? entries : entries && typeof entries === 'object' ? [entries] : []
56 const out: { path: string; version?: string }[] = []
57 for (const entry of list) {
58 if (!entry || typeof entry !== 'object' || typeof entry.installPath !== 'string' || !entry.installPath.trim()) continue
59 const version = typeof entry.version === 'string' && compareSemver(entry.version, entry.version) !== null ? entry.version
60 : slash(entry.installPath).split('/').pop()
61 out.push({ path: slash(entry.installPath), ...(version && compareSemver(version, version) !== null ? { version } : {}) })
62 }
63 return out.sort((a, b) => (b.version && a.version ? compareSemver(b.version, a.version) ?? 0 : a.version ? -1 : b.version ? 1 : 0))
64 } catch { return [] }
65}
66
67async function fileExists(files: CompanionFiles, path: string): Promise<boolean> {
68 const normalized = slash(path)
69 const cut = normalized.lastIndexOf('/')
70 if (cut <= 0) return false
71 const entries = await files.list(normalized.slice(0, cut)).catch(() => [])
72 const name = normalized.slice(cut + 1).toLowerCase()
73 return entries.some(entry => entry?.kind !== 'dir' && typeof entry?.name === 'string' && entry.name.toLowerCase() === name)
74}
75
76/**
77 * Pick the companion script. `configuredMissing` comes from a caller that already knows the
78 * configured file is absent (the preflight's `companion=MISSING`). An existing configured path wins;
79 * otherwise the newest semver among installed_plugins.json install paths and
80 * `~/.claude/plugins/cache/openai-codex/codex/<version>/` that actually contains the script.
81 */
82export async function resolveCompanion(files: CompanionFiles, home: string, configured: string, configuredMissing = false): Promise<CompanionResolution> {
83 const wanted = slash(configured.trim())
84 if (wanted && !configuredMissing) return { path: wanted, source: 'configured' }
85 const base = slash(home)
86 const candidates: { path: string; version: string; source: CompanionSource }[] = []
87 const installed = installedPaths(await files.read(`${base}/.claude/plugins/installed_plugins.json`).catch(() => null))
88 for (const entry of installed) {
89 if (entry.version) candidates.push({ path: `${entry.path}/${SCRIPT}`, version: entry.version, source: 'installed' })
90 }
91 const cacheRoot = `${base}/.claude/plugins/cache/openai-codex/codex`
92 const versions = (await files.list(cacheRoot).catch(() => []))
93 .filter(entry => entry?.kind === 'dir' && typeof entry.name === 'string' && compareSemver(entry.name, entry.name) !== null)
94 .map(entry => entry.name)
95 for (const version of versions) candidates.push({ path: `${cacheRoot}/${version}/${SCRIPT}`, version, source: 'cache' })
96 // Newest first; on a tie the installed record wins because it was pushed first and sort is stable.
97 candidates.sort((a, b) => compareSemver(b.version, a.version) ?? 0)
98 for (const candidate of candidates) {
99 if (wanted && candidate.path.toLowerCase() === wanted.toLowerCase()) continue
100 if (!await fileExists(files, candidate.path)) continue
101 return {
102 path: candidate.path, source: candidate.source, version: candidate.version,
103 ...(wanted ? { warning: `configured companionScript not found (${wanted}); using ${candidate.version}` } : {}),
104 }
105 }
106 return wanted
107 ? { path: wanted, source: 'configured', warning: `configured companionScript not found (${wanted}) and no installed Codex plugin was found` }
108 : { path: '', source: 'none', warning: 'companionScript is empty and no installed Codex plugin was found' }
109}
110hooks/fallback.ts 57 lines1// Decide whether a Codex dispatch should go ahead, fall back to Claude, or ask the operator.
2import type { FallbackMode } from './config'
3
4export const QUOTA_STALE_MS = 6 * 3_600_000
5
6export type CodexQuota = { at: string; limits: { label: string; percent: number; resetsAt?: string }[] } | null | undefined
7export type QuotaReading = { state: 'ok' | 'low' | 'unknown'; remaining: number | null; ageMs: number | null }
8export type FallbackDecision =
9 | { action: 'codex' }
10 | { action: 'claude'; reason: string }
11 | { action: 'ask'; reason: string }
12
13/**
14 * Lowest remaining percentage across the quota windows. A reading older than six hours,
15 * without a timestamp, or without windows is `unknown` and never triggers a fallback by itself.
16 */
17export function quotaReading(quota: CodexQuota, minPercent: number, now: number): QuotaReading {
18 if (!quota || !quota.limits.length) return { state: 'unknown', remaining: null, ageMs: null }
19 const at = Date.parse(quota.at)
20 if (!Number.isFinite(at)) return { state: 'unknown', remaining: null, ageMs: null }
21 const ageMs = Math.max(0, now - at)
22 const remaining = Math.min(...quota.limits.map(limit => Math.max(0, Math.min(100, Math.round(100 - limit.percent)))))
23 if (ageMs > QUOTA_STALE_MS) return { state: 'unknown', remaining, ageMs }
24 return { state: remaining < minPercent ? 'low' : 'ok', remaining, ageMs }
25}
26
27/** Why the preflight line (already filtered to this project's workspace) blocks Codex, or ''. */
28export function preflightProblem(line: string, companionPath: string): string {
29 const text = line.trim()
30 if (text.startsWith('STALE')) return 'Codex broker 過期(Codex app 已更新)'
31 if (/\bcodex=unavailable\b/.test(text)) return '找不到 Codex app/broker'
32 if (/\bcompanion=MISSING\b/.test(text)) return '找不到 codex-companion.mjs'
33 if (!companionPath.trim()) return '未設定且找不到 codex-companion.mjs'
34 return ''
35}
36
37export type FallbackInput = {
38 mode: FallbackMode
39 minPercent: number
40 quota: CodexQuota
41 preflight: string
42 companionPath: string
43 now: number
44}
45
46/** `off` keeps the 0.1 behavior: always try Codex. */
47export function decideCodexDispatch(input: FallbackInput): FallbackDecision {
48 if (input.mode === 'off') return { action: 'codex' }
49 const reasons: string[] = []
50 const problem = preflightProblem(input.preflight, input.companionPath)
51 if (problem) reasons.push(problem)
52 const quota = quotaReading(input.quota, input.minPercent, input.now)
53 if (quota.state === 'low') reasons.push(`Codex 額度剩 ${quota.remaining}%(低於 ${input.minPercent}%)`)
54 if (!reasons.length) return { action: 'codex' }
55 return { action: input.mode === 'claude' ? 'claude' : 'ask', reason: reasons.join(';') }
56}
57hooks/platform.ts 24 lines1// Per-platform commands: PowerShell scripts on Windows, POSIX sh scripts on macOS / Linux.
2
3export const isWindowsOs = (os: string | null | undefined) => os === 'Windows_NT'
4
5export function preflightArgs(pluginRoot: string, windows: boolean, script: string, stateDir: string, stateRoots: string[]): string[] {
6 if (windows) {
7 return ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', `${pluginRoot}/scripts/codex-preflight.ps1`,
8 ...(script ? ['-CompanionScript', script] : []), '-CompanionStateDir', stateDir]
9 }
10 const dirs = [...new Set([stateDir, ...stateRoots].filter(Boolean))]
11 return ['sh', `${pluginRoot}/scripts/codex-preflight.sh`, ...(script ? ['--companion-script', script] : []), ...dirs.flatMap(dir => ['--state-dir', dir])]
12}
13
14export function quotaArgs(pluginRoot: string, windows: boolean): string[] {
15 return windows
16 ? ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', `${pluginRoot}/scripts/codex-quota.ps1`]
17 : ['sh', `${pluginRoot}/scripts/codex-quota.sh`]
18}
19
20/** Fallback when the `code` editor CLI is unavailable: the OS default handler. */
21export function openFallbackArgs(path: string, windows: boolean): string[] {
22 return windows ? ['cmd', '/c', 'start', '', path.replace(/\//g, '\\')] : ['open', path]
23}
24hooks/jobs.ts 103 lines1import type { Job } from './logic'
2
3type StateFile = { updatedAt?: string; jobs?: unknown }
4export type JobFiles = {
5 list(path: string): Promise<{ name: string; kind: string }[]>
6 read(path: string): Promise<string>
7}
8
9const slash = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, '')
10
11function time(value: unknown): number | null {
12 if (typeof value !== 'string' || !value.trim()) return null
13 const parsed = Date.parse(value)
14 return Number.isNaN(parsed) ? null : parsed
15}
16
17/**
18 * state.json files that parsed but whose shape this version does not recognise, keyed by path.
19 * The companion is another plugin's internals; when it changes format the console would otherwise
20 * show no Codex jobs without saying why. A file that fails to parse is skipped silently (the
21 * companion may be mid-write); the next refresh reads it again.
22 */
23export const stateFormatIssues = new Map<string, string>()
24
25export function stateShapeIssue(state: unknown): string | null {
26 if (!state || typeof state !== 'object' || Array.isArray(state)) return '頂層不是物件'
27 const jobs = (state as StateFile).jobs
28 if (!Array.isArray(jobs)) return '缺少 jobs 陣列'
29 if (jobs.length && !jobs.some(job => !!job && typeof job === 'object' && typeof (job as { id?: unknown }).id === 'string')) return 'jobs 項目都沒有 id'
30 return null
31}
32
33function stateJobs(text: string, stateDir: string): { job: Job; updatedAt: number | null }[] {
34 let state: StateFile
35 const path = `${stateDir}/state.json`
36 try { state = JSON.parse(text) as StateFile } catch { return [] }
37 const issue = stateShapeIssue(state)
38 if (issue) stateFormatIssues.set(path, issue)
39 else stateFormatIssues.delete(path)
40 if (!Array.isArray(state?.jobs)) return []
41 const stateUpdatedAt = time(state.updatedAt)
42 const out: { job: Job; updatedAt: number | null }[] = []
43 for (const value of state.jobs) {
44 if (!value || typeof value !== 'object') continue
45 const raw = value as Record<string, unknown>
46 if (typeof raw.id !== 'string' || !raw.id.trim()) continue
47 const job: Job = { id: raw.id }
48 for (const key of ['jobClass', 'status', 'summary', 'createdAt', 'updatedAt', 'completedAt', 'startedAt', 'phase', 'logFile'] as const) {
49 if (typeof raw[key] === 'string') job[key] = raw[key]
50 }
51 if (raw.request && typeof raw.request === 'object') {
52 const source = raw.request as Record<string, unknown>
53 const request: NonNullable<Job['request']> = {}
54 for (const key of ['prompt', 'effort', 'model'] as const) if (typeof source[key] === 'string') request[key] = source[key]
55 if (Object.keys(request).length) job.request = request
56 }
57 if (typeof job.logFile === 'string' && job.logFile && !/^(?:[a-z]:)?\//i.test(slash(job.logFile))) {
58 job.logFile = `${slash(stateDir)}/${slash(job.logFile).replace(/^\/+/, '')}`
59 } else if (typeof job.logFile === 'string') {
60 job.logFile = slash(job.logFile)
61 }
62 out.push({ job, updatedAt: time(job.updatedAt) ?? stateUpdatedAt })
63 }
64 return out
65}
66
67/**
68 * Read all companion state roots in priority order. A workspace directory is
69 * `<project basename>-<hash>`. Duplicate job ids use the newest updatedAt;
70 * equal or missing timestamps retain the earlier root's copy.
71 */
72export async function readJobs(files: JobFiles, roots: string[], projectRoot: string): Promise<Job[]> {
73 const base = slash(projectRoot).split('/').pop() ?? ''
74 if (!base) return []
75 const selected = new Map<string, { job: Job; updatedAt: number | null }>()
76 for (const rawRoot of roots) {
77 const root = slash(rawRoot)
78 if (!root) continue
79 const entries = await files.list(root).catch(() => [])
80 for (const entry of entries) {
81 if (entry?.kind !== 'dir' || typeof entry.name !== 'string' || !entry.name.toLowerCase().startsWith(base.toLowerCase() + '-')) continue
82 const stateDir = `${root}/${entry.name}`
83 const text = await files.read(`${stateDir}/state.json`).catch(() => null)
84 if (typeof text !== 'string' || !text.trim()) { stateFormatIssues.delete(`${stateDir}/state.json`); continue }
85 for (const candidate of stateJobs(text, stateDir)) {
86 const current = selected.get(candidate.job.id)
87 if (!current || (candidate.updatedAt !== null && (current.updatedAt === null || candidate.updatedAt > current.updatedAt))) {
88 selected.set(candidate.job.id, candidate)
89 }
90 }
91 }
92 }
93 return [...selected.values()].map(value => value.job)
94}
95
96/** One line for the footer when any companion state file has an unrecognised shape, else ''. */
97export function stateFormatWarning(): string {
98 const [first] = stateFormatIssues
99 if (!first) return ''
100 const more = stateFormatIssues.size > 1 ? ` 等 ${stateFormatIssues.size} 個檔` : ''
101 return `Codex 工作狀態格式無法辨識(${first[0]}:${first[1]}${more});Codex 工作可能顯示不全,請更新 console-status`
102}
103hooks/pipeline.ts 155 lines1// The claude-console workflow as a pipeline: spec → build → sync → verify → review → release.
2// Derived only from what the console already reads (CARD, executor jobs, the latest local
3// verification); nothing here touches the disk. See workflow/claude-console/SKILL.md "Order of work".
4import type { Project, VerificationResult } from '../types'
5import { hasAsk, parseGate, saysNone } from './logic'
6
7export type StageId = 'spec' | 'build' | 'sync' | 'verify' | 'review' | 'release'
8/** done: passed · active: an executor is working on it · wait: needs the console or the user ·
9 * fail: evidence says it failed · todo: not reached. */
10export type StageStatus = 'done' | 'active' | 'wait' | 'fail' | 'todo'
11export type Stage = { id: StageId; label: string; status: StageStatus }
12export type Pipeline = { stages: Stage[]; current: StageId | null; note: string }
13
14export const STAGES: readonly { id: StageId; label: string }[] = [
15 { id: 'spec', label: '規格' },
16 { id: 'build', label: '實作' },
17 { id: 'sync', label: '同步' },
18 { id: 'verify', label: '驗證' },
19 { id: 'review', label: '審核' },
20 { id: 'release', label: '上線' },
21]
22const INDEX = Object.fromEntries(STAGES.map((stage, index) => [stage.id, index])) as Record<StageId, number>
23
24const PASS = /\b(?:pass(?:ed)?|ok|green)\b|通過|成功|✓|✔/i
25const FAIL = /\b(?:fail(?:ed|ing|ures?)?|error|red)\b|失敗|未通過|✕|✗|✘/i
26/** A zero count names no failure: `214 pass, 0 fail`, `0 failed`, `failures: 0`, `0 失敗`. */
27const ZERO_FAIL = /\b0\s*(?:fail(?:ed|ures?)?|errors?)\b|\b(?:fail(?:ed|ures?)?|errors?)\s*[:=]\s*0\b|\b0\s*個?失敗|失敗\s*[::=]?\s*0\b/gi
28
29/**
30 * The verdict a CARD 驗證 line records after its command (`→ PASS; verified …`). FAIL wins over
31 * PASS so `3 passed, 1 failed` reads as failed; neither word means no verdict.
32 */
33export function cardVerdict(note: string): 'pass' | 'fail' | null {
34 if (FAIL.test(note.replace(ZERO_FAIL, ''))) return 'fail'
35 if (PASS.test(note)) return 'pass'
36 return null
37}
38
39/** Latest verdict: the console's own run when it ran this command, else what the CARD records. */
40function verdict(p: Project, local?: VerificationResult): 'pass' | 'fail' | null {
41 if (local && local.command === p.verify) return local.ok ? 'pass' : 'fail'
42 return cardVerdict(p.verifyNote ?? '')
43}
44
45export function pipeline(p: Project, local?: VerificationResult): Pipeline {
46 const todo = (): Stage[] => STAGES.map(stage => ({ ...stage, status: 'todo' }))
47 if (!p.hasCard) return { stages: todo(), current: null, note: 'STATUS 卡不存在' }
48 const gate = parseGate(p.gate)
49 const gateStage: StageId | null = gate && gate.kind !== 'unknown' ? gate.kind as StageId : null
50 const running = p.jobs.some(job => job.kind === 'running')
51 const unsynced = !running && p.jobs.some(job => job.kind === 'newer')
52 const result = verdict(p, local)
53 const next = p.next.trim() && !saysNone(p.next)
54
55 let current: StageId | null
56 let status: StageStatus
57 let note: string
58 if (running) {
59 // A job under a gate is that gate's work (acceptance tests for spec, the review job for review).
60 current = gateStage === 'spec' || gateStage === 'review' ? gateStage : 'build'
61 status = 'active'
62 note = current === 'review' ? '審核任務執行中' : current === 'spec' ? '規格/驗收測試撰寫中' : '執行者實作中'
63 } else if (unsynced) {
64 current = 'sync'; status = 'wait'; note = '執行者已結束,結果待寫回 STATUS'
65 } else if (gateStage) {
66 current = gateStage; status = 'wait'
67 note = gateStage === 'release' ? '待使用者核准上線範圍' : `待主控台審核 ${gateStage} 關卡`
68 } else if (result === 'fail') {
69 current = 'verify'; status = 'fail'; note = '驗證未通過'
70 } else if (next) {
71 current = 'build'; status = 'wait'; note = `待繼續:${p.next.trim()}`
72 } else {
73 current = null; status = 'todo'; note = '閒置'
74 }
75 if (hasAsk(p)) {
76 status = 'wait'
77 note = `需決策:${p.ask}`
78 current ??= 'build'
79 }
80
81 const at = current === null ? -1 : INDEX[current]
82 const stages = STAGES.map((stage, index): Stage => {
83 if (index === at) return { ...stage, status }
84 if (stage.id === 'verify') {
85 // Verification is evidence, not a position: a failure shows wherever the work stands.
86 if (result === 'fail') return { ...stage, status: 'fail' }
87 if (result === 'pass' && (at === -1 || index < at)) return { ...stage, status: 'done' }
88 // The workflow sets review/release only after acceptance is green.
89 if (at > INDEX.verify) return { ...stage, status: 'done' }
90 return { ...stage, status: 'todo' }
91 }
92 if (at === -1) return { ...stage, status: result === 'pass' && index < INDEX.verify ? 'done' : 'todo' }
93 return { ...stage, status: index < at ? 'done' : 'todo' }
94 })
95 return { stages, current, note }
96}
97
98export const STAGE_GLYPH: Record<StageStatus, string> = { done: '●', active: '◉', wait: '◆', fail: '✕', todo: '○' }
99
100// ── Project mode: a Claude Code session opened inside one registered project ──
101
102const pathKey = (path: string) => {
103 const slashed = path.replace(/\\/g, '/').replace(/\/+$/, '')
104 return /^[a-z]:\//i.test(slashed) || slashed.startsWith('//') ? slashed.toLowerCase() : slashed
105}
106
107/** The registered project whose root is `cwd` or contains it (the deepest root wins). */
108export function projectForCwd<P extends { statusPath: string }>(projects: readonly P[], cwd: string | null, rootOf: (statusPath: string) => string): P | null {
109 if (!cwd) return null
110 const here = pathKey(cwd)
111 let best: P | null = null
112 let bestLength = -1
113 for (const project of projects) {
114 const root = pathKey(rootOf(project.statusPath))
115 if (!root || !(here === root || here.startsWith(root + '/'))) continue
116 if (root.length > bestLength) { best = project; bestLength = root.length }
117 }
118 return best
119}
120
121/**
122 * The system prompt section for project mode. It must not change while the session stays in the
123 * same project (a changing system prompt re-sends the whole conversation uncached), so it holds
124 * the project's identity and the CARD contract only; live progress goes with prompts instead.
125 */
126export function projectModeSection(p: Pick<Project, 'name' | 'statusPath'>): string {
127 return [
128 `【console-status 專案模式】這個工作階段位於已登錄專案「${p.name}」,STATUS:${p.statusPath}。`,
129 '流程:規格 → 實作 → 同步 → 驗證 → 審核 → 上線(claude-console workflow)。',
130 '- 動工前先讀 STATUS 的 CARD 區塊(<!-- CARD --> 到 <!-- /CARD -->),只讀需要的部分。',
131 '- 完成一段工作就更新 CARD:這次動工時讀到的 rev 為準,寫入前再重讀,只有這兩次讀到的不同才停下回報衝突、不覆寫(不要跟記憶裡更早的 rev 比);寫入 rev + 1、狀態、驗證(指令與實際結果)、下一步;更新欄寫現在的本機時間(先執行 date 或 Get-Date,不要猜)。',
132 '- 關卡欄只放一個值:無/spec:…/review:…/release:…。到關卡就停下並附上證據;不自行清除關卡,不執行 release 或任何正式環境變更。',
133 '- 需要使用者拍板的事寫進「等使用者」,不要自行決定。',
134 ].join('\n')
135}
136
137/** What changed since the last prompt that carried progress: compare by this. */
138export function progressSignature(p: Project, pl: Pipeline): string {
139 return JSON.stringify([p.updated, p.next, p.ask, p.gate ?? '', pl.current, pl.stages.map(stage => stage.status)])
140}
141
142/** The per-prompt progress note in project mode. */
143export function progressContext(p: Project, pl: Pipeline): string {
144 const current = pl.stages.find(stage => stage.id === pl.current)
145 const lines = [
146 `【專案進度|console-status】${p.name}:${pl.stages.map(stage => `${stage.label}${STAGE_GLYPH[stage.status]}`).join(' ')}`,
147 `目前:${current ? `${current.label}(${pl.note})` : pl.note}`,
148 ]
149 if (p.next.trim()) lines.push(`CARD 下一步:${p.next.trim()}`)
150 if (hasAsk(p)) lines.push(`等使用者:${p.ask}`)
151 const gate = parseGate(p.gate)
152 if (gate) lines.push(`關卡:${p.gate}`)
153 return lines.join('\n')
154}
155hooks/pipeline-view.tsx 44 lines1// Drawing for the workflow pipeline (hooks/pipeline.ts): a full line with stage names for cards,
2// the band and project mode, and a six-glyph strip for the project table.
3import type { Pipeline, Stage } from './pipeline'
4import { STAGE_GLYPH } from './pipeline'
5
6export type PipelinePalette = { green: string; teal: string; amber: string; purple: string; blue: string; red: string; faint: string; dim: string; text: string }
7
8/** A waiting stage takes the colour of whoever it waits for: gates purple, sync blue, the rest amber. */
9export function stageColor(stage: Stage, palette: PipelinePalette): string {
10 switch (stage.status) {
11 case 'done': return palette.green
12 case 'active': return palette.teal
13 case 'fail': return palette.red
14 case 'todo': return palette.faint
15 case 'wait': return stage.id === 'sync' ? palette.blue : stage.id === 'spec' || stage.id === 'review' || stage.id === 'release' ? palette.purple : palette.amber
16 }
17}
18
19/** Six one-column glyphs, one per stage, for a table cell. */
20export function pipelineParts(pl: Pipeline, palette: PipelinePalette): { t: string; c: string }[] {
21 return pl.stages.map(stage => ({ t: STAGE_GLYPH[stage.status], c: stageColor(stage, palette) }))
22}
23
24/** `● 規格 ─ ◉ 實作 ─ ○ 同步 …`, the current stage bold, then the note unless `noteless`. */
25export function pipelineLine(pl: Pipeline, palette: PipelinePalette, elements: any, key: string, opts: { noteless?: boolean } = {}) {
26 const { Box, Text } = elements
27 return <Box key={key} flexDirection="column">
28 <Box flexWrap="wrap">
29 {pl.stages.map((stage, index) => <Text key={key + '-' + stage.id} wrap="truncate-end">
30 {index > 0 && <Text color={pl.stages[index - 1]!.status === 'done' ? palette.green : palette.faint}>{' ─ '}</Text>}
31 <Text color={stageColor(stage, palette)} bold={stage.id === pl.current}>{STAGE_GLYPH[stage.status]} {stage.label}</Text>
32 </Text>)}
33 </Box>
34 {!opts.noteless && pl.note && <Text color={pl.current ? stageColor(pl.stages.find(stage => stage.id === pl.current)!, palette) : palette.dim} wrap="wrap">{pl.note}</Text>}
35 </Box>
36}
37
38/** One-line text form for narrow places (band, prompt context): `實作◉ · 待繼續:…`. */
39export function pipelineText(pl: Pipeline): string {
40 const strip = pl.stages.map(stage => STAGE_GLYPH[stage.status]).join('')
41 const current = pl.stages.find(stage => stage.id === pl.current)
42 return `${strip}${current ? ` ${current.label}` : ''}${pl.note ? ` · ${pl.note}` : ''}`
43}
44