SLOPSHOPPER

console-status

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

newpanebandguardcommandprompt
★ 66v0.19.0MITupdated 2026-10-09SaM-runtime/claude-console/plugins/console-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · console-status
│ ┃ 主控台總覽 ✕ › fix the failing auth test and add an audit log call │ ┃ 讀取各專案狀態中… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /console │ ⎿ console-status: 面板已開啟。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 主控台總覽
讀取各專案狀態中…
README

claude-console

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.

Try the demo

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.

Features

  • Every project in one table. One row per project: its state (● 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.
  • Decisions are pressed, not typed. Every option in a card can be pressed; with the action menu open, number keys answer the decisions in order (2 then 1 reads 1B 2-1) and Backspace takes the last pick back. ✎ 填入決策 or ✎ 做決定 puts the answer in the prompt; Enter sends it.
  • One-button actions. Verify, sync STATUS, continue to the next step, review a gate, open STATUS.md: right-click or 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).
  • Your choice of executor. Claude Code background agents by default; Codex for everything or per project, with a fallback to Claude when Codex is unusable or low on quota. A prompt sent with a project selected carries an executor digest (what it was last doing, its last three tool calls, its last words), and the project card's 執行者 section shows the same. See Dispatch settings.
  • Git, PR and CI without switching windows. Branch and ahead/behind, uncommitted line counts, stash, recent commits, a warning when the last fetch is old; ▸ 檔案 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.
  • Usage and cache. Claude's 5-hour, weekly and per-model limits and Codex's quota share one 用量 table, with an estimate of when the current pace runs out; the prompt cache's countdown, the cost of re-writing it once cold, and the last hit rate. See Usage pace and Prompt cache and cost.
  • Guards. Commands that cannot be taken back (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.
  • Only where you want it. By default only a session where you ran /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.
  • Updates from the pane. The footer shows the installed version; when a new one is out, press ⬆ 更新 or run /console update. See Upgrade.

Every change, version by version, is in CHANGELOG.md.

How it works

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.

Requirements

  • Windows or macOS (macOS uses the bundled POSIX sh probes; no PowerShell needed)
  • Claude Code 2.1.289 or later with mod support
  • A project registry and one STATUS file per registered project
  • For 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)

Install

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.

Configure

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
KeyPurposeDefault
registryPathMarkdown registry mapping names to STATUS files~/.claude/handoffs/projects-scope.md
dispatchSettingsPathShared executor, model, and effort file~/.claude/handoffs/dispatch.json
claudeSessionsPathManaged Claude session mapping~/.claude/handoffs/claude-sessions.json
executorFallback executor when settings are missing or invalidclaude
defaultModel / defaultEffortFallback model settings; empty uses the executor's native defaultEmpty
companionScriptCodex Companion script used by executor: codex; empty or stale paths auto-resolve to the newest installed Codex pluginEmpty
companionStateDirLegacy companion state root, also passed to Codex preflightSystem Temp codex-companion directory
companionStateRootsJSON array encoded as a string; overrides Codex job rootsPlugin data first, then legacy Temp
modelsCachePathCodex model and effort cache~/.codex/models_cache.json
codexFallbackWhat to do when Codex is unusable: ask, claude, or offask
codexMinQuotaPercentCodex quota (percent remaining) below which the fallback applies10
gitProbeon: 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
commandGuardask: an irreversible shell command asks you first (see Command guard); deny: refuse them; off: no guardask
loopGuardon: 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 noteon
cacheHinton: show the console's prompt-cache countdown and re-write cost (see Prompt cache and cost); off: hideon
cacheTtlauto (the TTL the API reports in the session transcript's usage; 5 minutes until one is seen), 5m or 1hauto
cacheWritePriceUSD per million cache-write tokens for the estimate; empty uses the model's list priceEmpty
autoSyncon: 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 syncson
suggestNexton: 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 suggestionson
activationauto: 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 sessionauto
projectModeauto: a session opened inside a registered project switches to project mode (see Pipeline and project mode); off: always the multi-project consoleauto

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.

Dispatch settings

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.

Per-project executor

Each project resolves its executor in this order:

  1. Pane override: projects["<root>"] in dispatch.json. Keys are project roots; Windows drive and UNC paths match case-insensitively.
  2. Registry: the optional Executor column of the registry table (claude, codex, manual, or blank). Registries without the column parse as before.
  3. Global: the 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.

Sync model

同步 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" } } }
}
  • Model names are free text: write whatever your account offers. A blank field falls back to the normal dispatch's model or effort, and a project's 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.
  • Every sync on its own model says so: the accepted notice reads (用 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.
  • The pane's 同步模型 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.

executor: claude

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.

executor: codex

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.

Companion auto-resolve

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.

Quota and broker fallback

Before each Codex dispatch the mod checks the latest probes:

  • the Codex quota battery reports less than codexMinQuotaPercent remaining (lowest window), or
  • the preflight reports a stale broker for this project's workspace, the Codex app or broker missing, or the companion script missing.

A quota reading older than six hours is shown but treated as unknown; it never triggers the fallback by itself. Then:

codexFallbackBehavior
ask (default)Does not dispatch. A toast and the project card state why and offer ⇢ 改用 Claude 派工, which sends the same prompt to Claude.
claudeSends the same prompt to the Claude executor and records fallbackFrom: "codex" and the reason on the job.
off0.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.

CLAUDE.md snippet

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.

STATUS cards

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.

Project actions

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.

ActionAvailabilityBehavior
▶ Run verificationCARD has 驗證Runs in the project root with a five-minute timeout; no model quota
⇢ Sync STATUSSYNC, executor not manualDispatches the project's executor to update only CARD and history; autoSync: on does this by itself once per finished job
⇢ ContinueIDLE, with a next step and no decision or gate; executor not manualRequires a second press within six seconds (the pane shows the next step it will dispatch), then dispatches the project's executor

| ↩ 回覆執行者 | A Claude ex

Source 28 files
hooks/register.tsx 2751 lines
1// 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 lines
1export 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}
73
hooks/logic.ts 945 lines
1// 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}
945
hooks/dispatch.ts 253 lines
1
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
253
hooks/executors.ts 585 lines
1import 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}
585
hooks/actions.ts 267 lines
1import 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}
267
hooks/companion.ts 110 lines
1// 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}
110
hooks/fallback.ts 57 lines
1// 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}
57
hooks/platform.ts 24 lines
1// 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}
24
hooks/jobs.ts 103 lines
1import 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}
103
hooks/pipeline.ts 155 lines
1// 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}
155
hooks/pipeline-view.tsx 44 lines
1// 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