Compare commits

...
9 Commits
Author SHA1 Message Date
yhh f4b815ecda fix(studio): a refused action's reason is no longer painted over by the composer
The window's error was rendered in .cmpalert, a corner layer fixed to the bottom
right at z-index 6, while the composer's tool row sits at z-index 8: the model
picker and the send button covered it. It now joins the stack above the composer
that the pane's own errors already use; passing notes stay in the corner. The
window's notices move to useWindowNotices so App.tsx stays under its ceiling.
2026-11-10 23:28:05 -08:00
yhh 7f4c9dc589 fix(studio): pnpm install of the frontend completes on Windows (#15067)
@a2ui/web_core uses redos-detector at run time to refuse regular expressions that
backtrack catastrophically, so the dependency has to stay. Every 6.1.x tarball
ships dist/__mocks__/package-json:version.d.ts, a name Windows cannot create, and
the install stopped on it. pnpm.overrides now points at the upstream 6.1.4 tarball
with that one entry removed (the other 44 are byte-identical; upstream integrity
and the removal are recorded in vendor/README.md), and a test fails if a vendored
package holds a name Windows cannot write.
2026-11-10 23:15:52 -08:00
yhh 8aa4ea7a87 feat(peers): open_projects can add folders inside the session's workspace without the card, if the user turns it on (#15090)
The open_projects card is a fresh per-request decision because trusting a folder
lets its own configuration run programs. peers.open_nested, off by default,
skips it only when every folder is strictly inside the asking session's own
workspace after links are resolved, and a notice lists the paths. A folder
outside, a missing one, a link that leaves the workspace, plan mode, a folder the
user declined, and the host's own vetting all behave as before.

The setting is user file only: a project's reasonix.toml cannot turn it on, the
setting tool cannot write it (Guarded), and a networked client without the
provider-edit grant is refused. Settings gets a switch, held inert until the
model may hand off tasks.
2026-11-10 23:08:06 -08:00
yhh a0af95ffab fix(cli): a Cygwin or MSYS terminal is told why the UI cannot run there (#15091)
Without a pseudoconsole mintty gives a program named pipes, so GetConsoleMode
fails and a bare reasonix printed the usage text while reasonix tui said it
needed an interactive terminal. Either end being such a pipe (named
msys-/cygwin-<id>-ptyN-from|to-master, with -nat on MSYS2) now prints a hint to
use cmd, PowerShell or Windows Terminal instead. Verified inside mintty with
MSYS=disable_pcon.
2026-11-10 22:22:15 -08:00
yhh 2cc5ad25f4 fix(terminal): a Windows without a pseudoconsole is told so, not that the shell failed to start (#15091)
conpty reports ErrConPtyUnsupported on a Windows before 1809; it was folded into
terminal.start_failed, and the pane showed only that the terminal could not
start. It now carries terminal.unsupported, refused with 501, and Studio says the
built-in terminal needs Windows 10 1809 or later. The terminal still does not open
on such a system.
2026-11-10 22:22:15 -08:00
yhh 07e6a61344 fix(studio): a display formula no longer loses the top of a raised exponent (#15092)
The block scrolls sideways when wide, which hides its vertical overflow too,
and it had padding only below. KaTeX draws exponents, tall delimiters and
operator limits past the line box, so the top of them was cut at the clip edge.
Padding is now .6em above and below with the margin reduced to .2em, so the gap
between blocks is unchanged. A test holds the padding symmetric.
2026-11-10 22:07:01 -08:00
yhh 3807e411d7 feat(studio): a process list across owners, read when opened
Controller.Processes lists the session's running jobs and the sub-agent runs
this process is executing. GET /processes serves it with a coded refusal when
the read fails; Studio adds a Processes chip to the run rail that reads on open
and re-reads only when its jobs or tasks change, never on a timer.

SubagentStore.LiveRuns reads the in-memory registry instead of scanning the
store directory (0.2 ms against 23 ms with 300 finished runs). A sub-agent
record now names the turn that spawned it (ParentTurn, the in-flight marker id
already held in memory), and the port methods for sub-agents and processes
move to their own RunningWorkPort so port.ts stays under its ceiling.
2026-11-10 22:01:19 -08:00
yhh 21f37476dc feat(process): one vocabulary for running work, and each owner's states mapped onto it
internal/contract/process is a closed set of states and typed causes. Each
owner (jobs, taskmonitor, delegation, schedule, sessioninbox, agentgraph) keeps
its own states and transitions and projects them with a pure function; a test
per owner reads the declared constants from source and fails when one has no
mapping. Nothing runs these on a hot path.
2026-11-10 22:01:18 -08:00
yhh 5cd38fde8f perf(sessionstore): archiving a session no longer canonicalizes every neighbour's path
recoveryArchiveLineage resolved each listed session through the filesystem to
look it up in an exclusion set that is empty on the common call, so one
archive cost time proportional to the directory: 342 ms at 400 sessions, and
quadratic over a batch. The cheap structural checks now run first and the
path is resolved only when there is something to exclude. 24 ms at 400.

Adds BenchmarkSetSessionLineageArchived.
2026-11-10 20:31:00 -08:00
102 changed files with 2014 additions and 62 deletions

No files matched your search

+5
View File
@@ -77,5 +77,10 @@
"typescript": "^7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
},
"pnpm": {
"overrides": {
"redos-detector": "file:./vendor/redos-detector-6.1.4.tgz"
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ class BenchPort extends MockPort {
// ?peerset=1 starts with session messages on and two projects already allowed through.
if (query.has("peerset")) {
this.peerSettings = {
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn", pending: query.has("pending"), available: true,
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, applies: "next_turn", pending: query.has("pending"), available: true,
path: "/Users/you/.reasonix/config.toml",
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
};
+8 -4
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
redos-detector: file:./vendor/redos-detector-6.1.4.tgz
importers:
.:
@@ -2475,8 +2478,9 @@ packages:
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==}
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
resolution: {integrity: sha512-0a7pybN7BYeVkQnDiL0GuELrvrvRYd4cDqV0T7G0+UsRAdg7SfMy60Vf0V4SPeftu6hD60FiDi5VANJsP9+ISg==, tarball: file:vendor/redos-detector-6.1.4.tgz}
version: 6.1.4
engines: {node: '>=16.0.0'}
hasBin: true
@@ -3049,7 +3053,7 @@ snapshots:
'@lit/context': 1.1.6
'@preact/signals-core': 1.14.4
lit: 3.3.3
redos-detector: 6.1.4
redos-detector: file:vendor/redos-detector-6.1.4.tgz
validate-color: 2.2.4
zod: 3.25.76
zod-to-json-schema: 3.25.2([email protected])
@@ -5599,7 +5603,7 @@ snapshots:
- '@types/react'
- redux
[email protected]:
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
dependencies:
regjsparser: 0.13.0
+2
View File
@@ -682,6 +682,7 @@ export const ACTIONS: UIAction[] = [
{ id: "reply.run-detail", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.processes", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.jump", kind: "navigation", target: "none", proof: "interaction" },
@@ -744,6 +745,7 @@ export const ACTIONS: UIAction[] = [
{ id: "peers.approve-project", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.open-nested", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "peers.release-add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-pick", kind: "view", target: "none", proof: "interaction" },
+2
View File
@@ -120,6 +120,7 @@ const SAID: Record<string, string> = {
"terminal.not_found": "kernel.that_terminal_is_not_open",
"terminal.shell_missing": "kernel.that_shell_is_not_on",
"terminal.start_failed": "kernel.the_shell_could_not_start",
"terminal.unsupported": "kernel.this_windows_has_no_built_in_terminal",
"terminal.exited": "kernel.the_shell_has_ended",
"terminal.limit": "kernel.too_many_terminals_are_open",
"workspace.file_missing": "kernel.the_file_was_not_found",
@@ -518,6 +519,7 @@ const SAID: Record<string, string> = {
"subagent.not_found": "kernel.no_sub_agent_run_was",
"subagent.not_owned": "kernel.that_sub_agent_belongs_to",
"subagent.read_failed": "kernel.could_not_read_the_sub",
"process.read_failed": "kernel.could_not_read_the_processes",
"subagent.not_running": "kernel.that_sub_agent_is_no",
"subagent.steer_empty": "kernel.guidance_cannot_be_empty",
"subagent.steer_too_large": "kernel.that_guidance_is_longer_than",
@@ -111,6 +111,7 @@ export const KERNEL: Messages = {
"kernel.could_not_reach_host_detail": { zh: "连接 {host} 失败:{detail}", en: "Could not reach {host}: {detail}" },
"kernel.could_not_reach_the_account": { zh: "连不上账号服务,请检查网络后重试", en: "Could not reach the account service; check the network and try again" },
"kernel.could_not_read_the_file": { zh: "文件预览读取失败,请重试", en: "Could not read the file preview. Try again." },
"kernel.could_not_read_the_processes": { zh: "读取进程列表失败:{detail}", en: "Could not read the process list: {detail}" },
"kernel.could_not_read_the_sub": { zh: "读取子代理记录失败:{detail}", en: "Could not read the sub-agent record: {detail}" },
"kernel.could_not_read_this_commit": { zh: "无法读取这个提交", en: "Could not read this commit" },
"kernel.could_not_read_this_file": { zh: "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。", en: "Could not read this file: {detail}. It may be open in another program, moved, or not yet downloaded from cloud storage." },
@@ -596,6 +597,7 @@ export const KERNEL: Messages = {
"kernel.the_session_workspace_or_runtime": { zh: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发", en: "The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again" },
"kernel.the_settings_were_written_but": { zh: "设置已写入,但运行时未能按新设置重建:{detail}", en: "The settings were written, but the runtime could not be rebuilt on them: {detail}" },
"kernel.the_sharing_listener_is_not": { zh: "共享入口配置不完整,无法接受连接。", en: "The sharing listener is not fully configured and cannot accept connections." },
"kernel.this_windows_has_no_built_in_terminal": { zh: "这个 Windows 版本不支持内置终端(需要 Windows 10 1809 或更高版本)", en: "The built-in terminal needs Windows 10 version 1809 or later" },
"kernel.the_shell_could_not_start": { zh: "终端没能启动", en: "The terminal could not start" },
"kernel.the_shell_has_ended": { zh: "终端里的程序已经结束,重新启动后再输入", en: "The shell has ended; restart it before typing" },
"kernel.the_shell_settings_were_not": { zh: "shell 设置未能保存:{detail}", en: "The shell settings were not saved: {detail}" },
@@ -79,6 +79,8 @@ export const REMOTE: Messages = {
"remote.last_used_at": { zh: "最近使用 {at}", en: "Last used {at}" },
"remote.leave_empty_to_let_the": { zh: "留空则每次开启时由系统随机选择,范围 1024–65535。更改端口后,手机需要重新扫码配对。", en: "Leave empty to let the system pick a port each time, or choose one from 1024 to 65535. After changing the port, phones need to scan a new code to pair again." },
"remote.left_blank_it_tries_in": { zh: "留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。", en: "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively." },
"remote.open_nested_folders_without_asking": { zh: "不询问直接打开本项目内的文件夹", en: "Open folders inside this project without asking" },
"remote.open_nested_folders_without_asking_ds": { zh: "模型用 open_projects 把本项目目录下的子文件夹加为项目时,直接添加并标为可信,不再弹确认卡;项目之外的文件夹仍然会询问。被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。", en: "When the model adds a subfolder of this project as a project with open_projects, it is added and trusted without a card; folders outside the project still ask. A trusted folder's configuration can run programs, so turn this on only for a repository you trust." },
"remote.let_the_model_hand_a": { zh: "允许模型把任务交给别的会话", en: "Let the model hand a task to another session" },
"remote.local_pairing_valid_until_time": { zh: "局域网配对 · {time} 前有效", en: "Local pairing · valid until {time}" },
"remote.lost_the_connection_to_machine": { zh: "与 {machine} 的连接已中断", en: "Lost the connection to {machine}" },
@@ -10,6 +10,26 @@ export const SUBAGENT: Messages = {
"subagent.deck.hint": { zh: "点一行在工作台打开", en: "Select a row to open it in the workbench" },
"subagent.deck.open_full": { zh: "查看完整记录:{name}", en: "Open full transcript: {name}" },
"subagent.deck.title": { zh: "本会话的子代理", en: "This session's subagents" },
"subagent.process.cause.adopted": { zh: "沿用已有结果", en: "Reused an earlier result" },
"subagent.process.cause.budget": { zh: "预算用尽", en: "Out of budget" },
"subagent.process.cause.claim": { zh: "等被占用的写入路径", en: "Waiting for a write path someone holds" },
"subagent.process.cause.held": { zh: "被搁置,尚未开始", en: "Held back, not started" },
"subagent.process.cause.parked": { zh: "停在无人回答的提问上", en: "Stopped on a question nobody could answer" },
"subagent.process.cause.slots": { zh: "等并发名额", en: "Waiting for a free slot" },
"subagent.process.cause.writers": { zh: "等写入名额", en: "Waiting for a writer slot" },
"subagent.process.chip": { zh: "进程", en: "Processes" },
"subagent.process.empty": { zh: "现在没有在运行的东西", en: "Nothing is running" },
"subagent.process.kind.job": { zh: "后台作业", en: "Background job" },
"subagent.process.kind.subagent": { zh: "子代理", en: "Sub-agent" },
"subagent.process.state.completed": { zh: "已完成", en: "Completed" },
"subagent.process.state.failed": { zh: "失败", en: "Failed" },
"subagent.process.state.interrupted": { zh: "已中断", en: "Interrupted" },
"subagent.process.state.pending": { zh: "等待上游", en: "Waiting on upstream" },
"subagent.process.state.queued": { zh: "排队中", en: "Queued" },
"subagent.process.state.running": { zh: "运行中", en: "Running" },
"subagent.process.state.skipped": { zh: "已跳过", en: "Skipped" },
"subagent.process.state.stopped": { zh: "已停止", en: "Stopped" },
"subagent.process.state.waiting": { zh: "等待中", en: "Waiting" },
"subagent.doing": { zh: "正在{what}", en: "Now: {what}" },
"subagent.full_transcript_of_subagent_name": { zh: "子代理完整记录:{name}", en: "Full transcript of subagent: {name}" },
"subagent.group.all_delivered": { zh: "{n} 个都已交付", en: "All {n} delivered" },
@@ -204,6 +204,9 @@ export interface PeersSettings {
// Ceilings on chained and simultaneous dispatches; 0 removes a ceiling.
max_dispatch_depth: number;
max_dispatched_sessions: number;
// The user's standing yes for open_projects to add folders inside the session's
// own workspace without a card; it applies at the next call.
open_nested: boolean;
path: string;
applies: "next_turn";
pending: boolean;
@@ -218,6 +221,7 @@ export interface PeersSettingsEdit {
release_projects?: string[];
max_dispatch_depth?: number;
max_dispatched_sessions?: number;
open_nested?: boolean;
}
export type PeerRemember = "project" | "none";
@@ -324,7 +324,7 @@ export class MockBoundary extends MockShell {
return mockMods(this.modConfig.enabled);
}
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
async peersSettings(): Promise<PeersSettings> {
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
@@ -338,6 +338,7 @@ export class MockBoundary extends MockShell {
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
open_nested: edit.open_nested ?? this.peerSettings.open_nested,
release_projects: keep ? keep.map((path) => this.peerSettings.release_projects.find((p) => p.path === path) ?? { path, name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path }) : this.peerSettings.release_projects,
pending: true,
};
@@ -1,6 +1,7 @@
import { MockMcp } from "./mock_mcp";
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { ProcessEntry } from "./process";
// Hooks, and what a dry run of one reports back.
export class MockHook extends MockMcp {
@@ -17,6 +18,10 @@ export class MockHook extends MockMcp {
return [];
}
async processes(): Promise<ProcessEntry[]> {
return [];
}
async subagentTranscript(_ref: string): Promise<SubagentTranscript> {
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
}
+2 -7
View File
@@ -28,7 +28,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { RunningWorkPort } from "./running";
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
import type { SessionTailPort } from "./session_tail";
@@ -181,7 +181,7 @@ export interface TrayPrefs {
closeToTray: boolean;
}
export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
export interface AgentPort extends RunningWorkPort, MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
providerSetup(): Promise<ProviderSetup | null>;
saveProviderKey(apiKey: string): Promise<void>;
// The models that answer the job asked about; "chat" when none is named.
@@ -708,11 +708,6 @@ export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, Session
// Ends one running tool call and lets the turn go on; a call that already
// finished is refused (tool_call.not_running).
stopToolCall(callId: string): Promise<void>;
// The sub-agent runs this conversation spawned, running or finished, newest
// first, and one run's conversation a page at a time: a running run is read
// live. A run of another conversation is refused (subagent.not_owned).
subagents(): Promise<SubagentRun[]>;
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
// The person's guidance to a sub-agent that is still running, read at its next
// tool call. Refused with a code when it is not running (subagent.not_running),
// is empty, over the size or rate limit, or is another conversation's.
+19
View File
@@ -0,0 +1,19 @@
/** Where a unit of work stands, in terms every kind of work can be mapped onto. */
export type ProcessState = "pending" | "queued" | "running" | "waiting" | "completed" | "failed" | "stopped" | "interrupted" | "skipped";
/** What kind of work a process is: the owner that produced it. */
export type ProcessKind = "subagent" | "job";
/** One unit of work this session has running now: GET /processes. */
export interface ProcessEntry {
/** "<kind>:<owner id>". */
id: string;
kind: ProcessKind;
/** The process that started this one; absent for a root. */
parent?: string;
name: string;
state: ProcessState;
/** Why it is in this state, when the owner records one. */
cause?: string;
startedAt: number;
}
+14
View File
@@ -0,0 +1,14 @@
import type { ProcessEntry } from "./process";
import type { SubagentRun, SubagentTranscript } from "./subagent";
/** What this conversation has handed off or has running. */
export interface RunningWorkPort {
// The sub-agent runs this conversation spawned, running or finished, newest
// first, and one run's conversation a page at a time: a running run is read
// live. A run of another conversation is refused (subagent.not_owned).
subagents(): Promise<SubagentRun[]>;
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
// What this session has running now across owners, read when asked and never
// pushed: a window that does not open the list pays nothing for it.
processes(): Promise<ProcessEntry[]>;
}
@@ -2,6 +2,7 @@ import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, Wor
import type { DiscardRequest, GitCommitFiles, GitHistory, GitRepos, PushPlan, PushRequest, PushResult } from "./workspace";
import type { JobOutput, ToolOutputPage } from "./session";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { ProcessEntry } from "./process";
import { SseFeedback } from "./sse_feedback";
import { host } from "./host";
import { HttpError } from "./http_error";
@@ -82,6 +83,9 @@ export class SseWorkspace extends SseFeedback {
subagents() {
return this.get<SubagentRun[]>("/subagents");
}
processes() {
return this.get<ProcessEntry[]>("/processes");
}
subagentTranscript(ref: string, after = 0, limit = 0) {
const query = [after ? `after=${after}` : "", limit ? `limit=${limit}` : ""].filter(Boolean).join("&");
return this.get<SubagentTranscript>("/subagents/" + encodeURIComponent(ref) + "/transcript" + (query ? "?" + query : ""));
@@ -1750,3 +1750,13 @@ A sub-agent row, by the list's own width:
- Hover waits briefly before opening and closing so the pointer can cross to
the card. Click and keyboard activation also work; Escape restores focus.
The portaled card follows its anchor and stays inside the viewport.
## Display math
- A display formula scrolls sideways when it is wider than the column, which
makes its vertical overflow hidden as well. KaTeX draws raised exponents, tall
delimiters and big-operator limits past the line box, so the block carries
`.6em` of padding above and below and only `.2em` of margin: the clip edge
sits outside everything a formula draws, and the gap between blocks is
unchanged to the eye. Padding on one side only leaves the other flush with
the clip.
+1 -1
View File
@@ -3550,7 +3550,7 @@
}
.md .katex { font-size: 1.02em }
.md .katex-display { margin: .6em 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px }
.md .katex-display { margin: .2em 0; overflow-x: auto; overflow-y: hidden; padding: .6em 0 }
.md-tw { margin: 0 0 .7em; overflow-x: auto }
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import app from "./app.css?raw";
const rule = (selector: string): string => {
const at = app.indexOf(selector + " {");
return at < 0 ? "" : app.slice(at, app.indexOf("}", at));
};
const em = (value: string): number => (value.endsWith("em") ? parseFloat(value) : NaN);
describe("a display formula in a message", () => {
const block = rule(".md .katex-display");
it("scrolls sideways when it is too wide", () => {
expect(block).toMatch(/overflow-x:\s*auto/);
});
it("leaves room above and below for what a raised exponent or a tall delimiter draws past the line", () => {
expect(block).toMatch(/overflow-y:\s*hidden/);
const pad = block.match(/padding:\s*([\d.]+em)\s+0/);
expect(pad, "vertical padding in em on both sides").not.toBeNull();
expect(em(pad![1])).toBeGreaterThanOrEqual(0.55);
});
it("is not given a bottom-only allowance that leaves the top flush with the clip edge", () => {
expect(block).not.toMatch(/padding-bottom/);
});
});
+5 -9
View File
@@ -5,8 +5,7 @@ import { keepSame } from "./stable";
import { usePaneCallbacks } from "./usePaneCallbacks";
import { watchTreeWake } from "./treewake";
import { usePinnedSessions } from "./usePinnedSessions";
import { AutoArchiveNotice } from "./AutoArchiveNotice";
import { LevelUpNotice } from "./LevelUpNotice";
import { useWindowNotices } from "./useWindowNotices";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { reason } from "../i18n/kernel";
import { t } from "../i18n";
@@ -20,7 +19,6 @@ import { Nav } from "./Nav";
import { useTreeWindow, type TreePaging } from "./treewindow";
import { useNavRail } from "./navrail";
import { feedbackEntryTab } from "./feedbackentry";
import { ErrorBar } from "./ErrorBar";
import { noteFault } from "./fault";
import { ToastStack } from "./ToastStack";
import { useLinkRouting } from "./links";
@@ -481,11 +479,8 @@ export function App({ hub }: { hub: HubPort }) {
// A failed request floats above the composer of the pane in front, where the
// eye already is; with no pane on screen it takes the main area's corner.
const activePaneShown = !!activeRuntime && panePorts.has(activeRuntime.id);
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={() => setFeedback("send")} /> : null), [error]);
const bar = useMemo(
() => errorBar ?? <LevelUpNotice port={machineRt ? machinePort : null} tick={feedback === null ? 0 : 1} fallback={<AutoArchiveNotice tree={tree} />} />,
[errorBar, tree, machineRt, machinePort, feedback],
);
const reportError = useCallback(() => setFeedback("send"), []);
const { errorBar, note, bar } = useWindowNotices({ error, setError, onReport: reportError, tree, port: machineRt ? machinePort : null, feedbackOpen: feedback !== null });
const activeWorkspace = tree.find((ws) => !activeRuntime?.host && workspaceContains(ws, activeRuntime?.root)) ?? tree[0];
const newSessionRoot = activeWorkspace?.root;
const startConversation = useNewConversation(active, runtimes, newSessionRoot, openPane, openRemotePane);
@@ -704,7 +699,8 @@ export function App({ hub }: { hub: HubPort }) {
terminalPulse={terminalRequests[rt.id] ?? 0}
viewRequest={viewRequests[rt.id]}
sessionRef={sessionRefs[rt.id]}
alert={rt.id === active ? bar : undefined}
alert={rt.id === active && !errorBar ? note : undefined}
failure={rt.id === active ? errorBar : undefined}
connect={first.connect}
needsProject={needsProject}
onOpenProject={openProject}
+39 -2
View File
@@ -1,25 +1,31 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JobEntry, JobOutput } from "../port/port";
import type { ProcessEntry } from "../port/process";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { StudioIcon } from "./StudioIcon";
import { agentsIn } from "./panels/derive";
import { AgentDeck } from "./AgentDeck";
import { AgentTranscript } from "./panels/AgentTranscript";
import { JobDeck } from "./JobDeck";
import { ProcessDeck } from "./ProcessDeck";
import { JobOutputView } from "./panels/JobOutputView";
import type { Task } from "./panels/Agents";
import { useDismiss } from "./dismiss";
export type Deck = "" | "agents" | "jobs";
export type Deck = "" | "agents" | "jobs" | "processes";
/** What is running on this session's behalf but is not in the transcript: work
* handed to a sub-agent, and processes left running in the background. The
* readings sit at the right end of the run rail, beside the other numbers
* about this turn, and each opens on hover the way the context reading does —
* one gesture for every summary on this row. */
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void }) {
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, onReadProcesses }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void; onReadProcesses?: () => Promise<ProcessEntry[]> }) {
const agentsBox = useRef<HTMLDivElement>(null);
const jobsBox = useRef<HTMLDivElement>(null);
const processBox = useRef<HTMLDivElement>(null);
const [processes, setProcesses] = useState<ProcessEntry[]>([]);
const [processError, setProcessError] = useState("");
const [viewing, setViewing] = useState("");
const [viewingJob, setViewingJob] = useState("");
const viewed = viewing ? tasks.find((x) => x.id === viewing) : undefined;
@@ -38,6 +44,16 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
};
useDismiss(open === "agents", agentsBox, shut);
useDismiss(open === "jobs", jobsBox, shut);
useDismiss(open === "processes", processBox, shut);
useEffect(() => {
if (open !== "processes" || !onReadProcesses) return;
let current = true;
onReadProcesses().then(
(rows) => { if (current) { setProcesses(rows); setProcessError(""); } },
(e: unknown) => { if (current) { setProcesses([]); setProcessError(reason(e)); } },
);
return () => { current = false; };
}, [open, onReadProcesses, tasks, jobs]);
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
// A failure stays loud until the list has been opened on it.
@@ -79,6 +95,27 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
{viewingJob && onReadJob && jobs.some((j) => j.id === viewingJob) && (
<JobOutputView job={jobs.find((j) => j.id === viewingJob)!} read={onReadJob} onClose={() => setViewingJob("")} onCancel={onCancelJob} />
)}
{onReadProcesses && (
<div ref={processBox} className="studio-deck-anchor" data-open={open === "processes" ? "" : undefined}>
<button
type="button"
className="studio-deckchip"
data-action="deck.processes"
aria-label={t("subagent.process.chip")}
title={t("subagent.process.chip")}
data-live={liveAgents || liveJobs ? "" : undefined}
aria-expanded={open === "processes"}
aria-haspopup="dialog"
onClick={() => onOpen((was) => (was === "processes" ? "" : "processes"))}
>
<StudioIcon name="play" />
<span>{t("subagent.process.chip")}</span>
</button>
<div className="studio-deck-pop" role="dialog" aria-label={t("subagent.process.chip")}>
<ProcessDeck rows={processes} error={processError} />
</div>
</div>
)}
{jobs.length > 0 && (
<div ref={jobsBox} className="studio-deck-anchor" data-open={open === "jobs" ? "" : undefined}>
<button
+4 -2
View File
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import { money } from "../i18n/format";
import type { ContextBreakdown, JobEntry, JobOutput, McpEntry, SessionUsage } from "../port/port";
import type { CostQuote } from "../port/wire";
import type { ProcessEntry } from "../port/process";
import { cacheRate as rateOf } from "../state/overview";
import { SessionOverviewCard } from "./SessionOverviewCard";
import type { CurrencyAmount, Metrics } from "../state/session_types";
@@ -20,7 +21,7 @@ import { MASK, type Wallet } from "./wallet";
import type { SpeedSummary } from "./speed";
/** The numbers about this turn, in one row under the composer. */
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onReadProcesses, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
tps: number;
trail: number[];
running: boolean;
@@ -40,6 +41,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
onSettings: (section?: string) => void;
onCancelJob: (id: string) => Promise<void>;
onReadJob?: (id: string) => Promise<JobOutput>;
onReadProcesses?: () => Promise<ProcessEntry[]>;
onOpenRun?: (call: string) => Promise<boolean>;
onStopCall?: (callId: string) => void;
onClearJobs?: (ids: string[]) => void;
@@ -115,7 +117,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
{cost.length > 0 && turnCost.some((m) => m.amount > 0) && <span className="studio-meter-cost"><span>{t("composer.this_turn")}</span>{hideAmounts ? <b>{MASK}</b> : turnCost.map((m) => <b key={m.currency}>{money(m.amount, m.currency)}</b>)}</span>}
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} title={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("common.insufficient_balance")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("composer.view_wallet_balance") : `${t("composer.view_wallet_balance")}, ${t("common.insufficient_balance")}`} onClick={() => onSettings("spend")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onReadProcesses={onReadProcesses} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
</div>
);
}
+4 -1
View File
@@ -103,7 +103,7 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert, failure }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
@@ -200,6 +200,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
}, [pacer]);
const readJob = useCallback((id: string) => port.jobOutput(id), [port]);
const readProcesses = useCallback(() => port.processes(), [port]);
const refreshStatus = useStatusRefresh(port, applyStatus, statusRead);
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
@@ -672,6 +673,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
onCancelJob={(id) => cancelJob(port, id, notify, refreshStatus).then(() => {})}
onReadJob={readJob}
onReadProcesses={readProcesses}
onOpenRun={openRun} onStopCall={(id) => void port.stopToolCall(id).catch(notify)}
onClearJobs={(ids) => setClearedJobs((was) => new Set([...was, ...ids]))}
onOpenJob={jobsLive.open}
@@ -682,6 +684,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
both are dismissed one at a time, so nothing else bounds how many can
be on screen at once. */}
<div className="composenotes">
{failure}
{s.error && (
<ErrorBar text={s.error} onDismiss={() => dispatch({ kind: "__error", text: "" } as never)} onReport={onFeedback} />
)}
@@ -47,6 +47,13 @@ export function PeerSettings({ port, settings, onSaved }: { port: AgentPort; set
</span>
<Switch data-action="peers.dispatch" on={state.dispatch} busy={off || !state.enabled} label={t("remote.let_the_model_hand_a")} onClick={() => void save({ dispatch: !state.dispatch })} />
</div>
<div className="lrow">
<span className="tx">
<span className="lb">{t("remote.open_nested_folders_without_asking")}</span>
<span className="ds">{t("remote.open_nested_folders_without_asking_ds")}</span>
</span>
<Switch data-action="peers.open-nested" on={state.open_nested} busy={off || !state.enabled || !state.dispatch} label={t("remote.open_nested_folders_without_asking")} onClick={() => void save({ open_nested: !state.open_nested })} />
</div>
{!state.available && <div className="peernote" role="status">{t("remote.not_supported_in_this_session")}</div>}
{state.pending && <div className="peernote" role="status">{t("remote.saved_takes_effect_from_the")}</div>}
<div className="peernote">{t("remote.these_switches_are_kept_in")}<code>{state.path}</code></div>
@@ -0,0 +1,60 @@
import { t } from "../i18n";
import type { ProcessEntry } from "../port/process";
import { jobElapsed } from "./panels/jobElapsed";
function stateLabel(state: string): string {
switch (state) {
case "pending": return t("subagent.process.state.pending");
case "queued": return t("subagent.process.state.queued");
case "running": return t("subagent.process.state.running");
case "waiting": return t("subagent.process.state.waiting");
case "completed": return t("subagent.process.state.completed");
case "failed": return t("subagent.process.state.failed");
case "stopped": return t("subagent.process.state.stopped");
case "interrupted": return t("subagent.process.state.interrupted");
case "skipped": return t("subagent.process.state.skipped");
default: return state;
}
}
function causeLabel(cause: string): string {
switch (cause) {
case "slots": return t("subagent.process.cause.slots");
case "writers": return t("subagent.process.cause.writers");
case "claim": return t("subagent.process.cause.claim");
case "held": return t("subagent.process.cause.held");
case "adopted": return t("subagent.process.cause.adopted");
case "budget": return t("subagent.process.cause.budget");
case "parked": return t("subagent.process.cause.parked");
default: return cause;
}
}
function kindLabel(kind: string): string {
switch (kind) {
case "subagent": return t("subagent.process.kind.subagent");
case "job": return t("subagent.process.kind.job");
default: return kind;
}
}
/** What this session has running now, one row per process. State and cause are
* the kernel's words for it; a value this page has no wording for is shown as
* it came rather than guessed at. */
export function ProcessDeck({ rows, error }: { rows: ProcessEntry[]; error: string }) {
if (error) return <div className="empty" role="alert">{error}</div>;
if (rows.length === 0) return <div className="empty">{t("subagent.process.empty")}</div>;
return (
<div className="job-deck">
{rows.map((p) => (
<div className="job-deck-row" key={p.id} data-status={p.state}>
<div className="job-deck-line">
<b className="job-deck-name">{p.name}</b>
<span className="job-deck-time">{[kindLabel(p.kind), stateLabel(p.state), jobElapsed(Date.now() - p.startedAt)].join(" · ")}</span>
</div>
{p.cause && <span className="job-deck-tail">{causeLabel(p.cause)}</span>}
</div>
))}
</div>
);
}
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import "./testkit";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { RuntimeView } from "../port/hub";
afterEach(cleanup);
function mount(extra: Record<string, unknown>) {
return render(<Pane port={new MockPort()} rt={{ id: "p1", root: "/w", name: "w" } as RuntimeView}
title="w" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}}
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
onOpenProject={() => {}} onKeepHere={() => {}} theme="dark" dockW={560} dockMax={880} onDockW={() => {}} {...extra} />);
}
describe("where a refused action's reason sits in the pane", () => {
it("is in the stack above the composer, not in the corner layer the composer paints over", async () => {
const view = mount({ failure: <div className="errbar" role="alert" data-testid="refusal">这个文件夹里有正在运行的对话</div> });
const refusal = await view.findByTestId("refusal");
expect(refusal.parentElement?.classList.contains("composenotes")).toBe(true);
expect(refusal.closest(".cmpalert")).toBeNull();
});
it("leaves a note, which has no business over the composer, in the corner layer", async () => {
const view = mount({ alert: <div className="errbar" data-kind="note" role="status" data-testid="note">升级了</div> });
const note = await view.findByTestId("note");
expect(note.closest(".cmpalert")).not.toBeNull();
expect(note.closest(".composenotes")).toBeNull();
});
});
+4 -1
View File
@@ -78,6 +78,9 @@ export interface PaneProps {
onDockW: (w: number) => void;
manualBrowser?: boolean;
onManualBrowser?: (on: boolean) => void;
// The window's failed-request notice, handed only to the pane in front.
// The window's passing notes, handed only to the pane in front.
alert?: ReactNode;
// The window's refused-action reason, handed only to the pane in front; it joins
// the stack above the composer rather than a corner the composer paints over.
failure?: ReactNode;
}
@@ -25,7 +25,7 @@ function Projects({ port, hub }: { port: AgentPort; hub: Parameters<typeof PeerP
}
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
});
const ws = (root: string, name: string, remembered = true): TreeWorkspace => ({ root, name, remembered, sessions: [] });
@@ -18,7 +18,7 @@ afterEach(() => {
});
const base: PeersSettings = {
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8,
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false,
path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true,
};
@@ -25,7 +25,7 @@ afterEach(() => {
});
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
});
function portWith(initial: PeersSettings) {
@@ -37,6 +37,7 @@ function portWith(initial: PeersSettings) {
...current,
enabled: edit.enabled ?? current.enabled,
dispatch: edit.dispatch ?? current.dispatch,
open_nested: edit.open_nested ?? current.open_nested,
release_projects: edit.release_projects ? current.release_projects.filter((p) => edit.release_projects!.includes(p.path)) : current.release_projects,
pending: true,
};
@@ -82,6 +83,27 @@ describe("the session messages switches", () => {
expect(save).toHaveBeenCalledExactlyOnceWith({ dispatch: true });
});
it("offers opening folders inside the project without asking, off until the user turns it on", async () => {
const { port, save } = portWith(settings({ enabled: true, dispatch: true }));
render(<Messages port={port} />);
const name = "不询问直接打开本项目内的文件夹";
const sw = await screen.findByRole("switch", { name });
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(screen.getByText(/项目之外的文件夹仍然会询问/)).toBeTruthy();
await userEvent.click(sw);
expect(save).toHaveBeenCalledExactlyOnceWith({ open_nested: true });
await waitFor(() => expect(screen.getByRole("switch", { name }).getAttribute("aria-checked")).toBe("true"));
});
it("holds the open-folders switch inert until the model may hand off tasks", async () => {
const { port, save } = portWith(settings({ enabled: true, dispatch: false }));
render(<Messages port={port} />);
const sw = await screen.findByRole("switch", { name: "不询问直接打开本项目内的文件夹" });
expect((sw as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(sw);
expect(save).not.toHaveBeenCalled();
});
it("names the file the switches live in, and shows a refusal beside them", async () => {
const { port } = portWith(settings({ enabled: true }));
vi.spyOn(port, "savePeersSettings").mockRejectedValue(new HttpError(500, "x", { code: "peer.settings_unwritable", params: { detail: "/home/me/.reasonix/config.toml" } }));
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import "./testkit";
import { DeckChips, type Deck } from "./DeckChips";
import type { JobEntry } from "../port/port";
import type { ProcessEntry } from "../port/process";
import { HttpError } from "../port/http_error";
afterEach(cleanup);
const JOBS: JobEntry[] = [{ id: "j1", kind: "bash", label: "npm run build", status: "running", startedAt: Date.now() - 5000 }];
const ROWS: ProcessEntry[] = [
{ id: "job:j1", kind: "job", name: "npm run build", state: "running", startedAt: Date.now() - 5000 },
{ id: "subagent:sa_1", kind: "subagent", parent: "turn:t3", name: "audit", state: "queued", cause: "writers", startedAt: Date.now() - 2000 },
];
function Host({ read, jobs = JOBS }: { read?: () => Promise<ProcessEntry[]>; jobs?: JobEntry[] }) {
const [open, setOpen] = useState<Deck>("");
return <DeckChips tasks={[]} jobs={jobs} open={open} onOpen={(next) => setOpen(next)} onReadProcesses={read} />;
}
const chip = () => document.querySelector<HTMLButtonElement>('[data-action="deck.processes"]')!;
it("reads nothing until the list is opened", async () => {
const read = vi.fn().mockResolvedValue(ROWS);
render(<Host read={read} />);
await act(async () => { await Promise.resolve(); });
expect(read).not.toHaveBeenCalled();
await userEvent.click(chip());
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
});
it("shows each process with its state and the cause the kernel gave", async () => {
render(<Host read={vi.fn().mockResolvedValue(ROWS)} />);
await userEvent.click(chip());
const dialog = await screen.findByRole("dialog", { name: "进程" });
expect(dialog.textContent).toContain("npm run build");
expect(dialog.textContent).toContain("运行中");
expect(dialog.textContent).toContain("audit");
expect(dialog.textContent).toContain("排队中");
expect(dialog.textContent).toContain("等写入名额");
});
it("does not poll while open, and reads again when the jobs it was built from change", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const read = vi.fn().mockResolvedValue(ROWS);
const { rerender } = render(<Host read={read} />);
await userEvent.click(chip());
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
await act(async () => { vi.advanceTimersByTime(120_000); });
expect(read).toHaveBeenCalledTimes(1);
rerender(<Host read={read} jobs={[...JOBS, { id: "j2", kind: "bash", label: "go test", status: "running", startedAt: Date.now() }]} />);
await waitFor(() => expect(read).toHaveBeenCalledTimes(2));
} finally { vi.useRealTimers(); }
});
it("says so when the read fails and keeps no stale rows", async () => {
const read = vi.fn().mockResolvedValueOnce(ROWS).mockRejectedValueOnce(new HttpError(500, "disk gone", { code: "process.read_failed", params: { detail: "disk gone" } }));
render(<Host read={read} />);
await userEvent.click(chip());
await screen.findByText("audit");
await userEvent.click(chip());
await userEvent.click(chip());
await waitFor(() => expect(screen.queryByText("audit")).toBeNull());
expect(screen.getByRole("alert").textContent).toContain("读取进程列表失败:disk gone");
});
it("offers no process chip where the window cannot read processes", () => {
render(<Host />);
expect(chip()).toBeNull();
});
@@ -9,6 +9,7 @@ export const SETTING_SCREEN: Record<string, string> = {
"agent.persona.include_runtime_context": "persona:persona",
"peers.enabled": "peers:peers",
"peers.dispatch": "peers:peers",
"peers.open_nested": "peers:peers",
"peers.release_projects": "peers:peer-projects",
"desktop.default_tool_approval_mode": "tools:approval",
"agent.plan_approval": "session:plan-approval",
@@ -0,0 +1,24 @@
import { useMemo, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { AgentPort } from "../port/port";
import type { TreeWorkspace } from "../port/hub";
import { AutoArchiveNotice } from "./AutoArchiveNotice";
import { ErrorBar } from "./ErrorBar";
import { LevelUpNotice } from "./LevelUpNotice";
/** What the window says about itself. A refused action's reason is the error bar
* and takes the place of the passing notes, which fill it when there is none. */
export function useWindowNotices({ error, setError, onReport, tree, port, feedbackOpen }: {
error: string;
setError: Dispatch<SetStateAction<string>>;
onReport: () => void;
tree: TreeWorkspace[];
port: AgentPort | null;
feedbackOpen: boolean;
}): { errorBar: ReactNode; note: ReactNode; bar: ReactNode } {
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={onReport} /> : null), [error, setError, onReport]);
const note = useMemo(
() => <LevelUpNotice port={port} tick={feedbackOpen ? 1 : 0} fallback={<AutoArchiveNotice tree={tree} />} />,
[tree, port, feedbackOpen],
);
return { errorBar, note, bar: errorBar ?? note };
}
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
import { describe, expect, it } from "vitest";
const ILLEGAL = /[<>:"|?*\u0000-\u001f]/;
function entryNames(tgz: Buffer): string[] {
const tar = gunzipSync(tgz);
const names: string[] = [];
for (let at = 0; at + 512 <= tar.length; ) {
const header = tar.subarray(at, at + 512);
if (header.every((b) => b === 0)) break;
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
const size = parseInt(header.subarray(124, 136).toString("utf8").replace(/\0.*$/, "").trim() || "0", 8);
names.push(prefix ? `${prefix}/${name}` : name);
at += 512 + Math.ceil(size / 512) * 512;
}
return names;
}
describe("a package vendored for installs on Windows", () => {
const dir = new URL("../vendor/", import.meta.url);
it("holds no file name Windows cannot create", () => {
const names = entryNames(readFileSync(new URL("redos-detector-6.1.4.tgz", dir)));
expect(names.length).toBeGreaterThan(10);
expect(names.filter((n) => ILLEGAL.test(n))).toEqual([]);
});
it("is the package the override says it is", () => {
const names = entryNames(readFileSync(new URL("redos-detector-6.1.4.tgz", dir)));
expect(names).toContain("package/package.json");
expect(names).toContain("package/LICENSE");
expect(names).toContain("package/dist/redos-detector.js");
});
it("is the one package.json points the override at", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
expect(pkg.pnpm.overrides["redos-detector"]).toBe("file:./vendor/redos-detector-6.1.4.tgz");
});
});
+31
View File
@@ -0,0 +1,31 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-11-10
---
# Vendored packages
`pnpm.overrides` in `package.json` points `redos-detector` at the tarball here, so that `pnpm install` can finish on Windows.
## Why
`@a2ui/web_core` depends on `redos-detector`, and its `isSafeRegex` uses it at run time to refuse regular expressions that can backtrack catastrophically. The check must stay.
Every published `redos-detector` 6.1.x tarball contains `dist/__mocks__/package-json:version.d.ts`. A colon is not a legal character in a Windows file name, so a package manager cannot write that path and the install stops with `os error 123` (issue #15067). Nothing reads the file: it is a type stub for a build-time module.
## What is changed
- `redos-detector-6.1.4.tgz` is the upstream 6.1.4 tarball with that one entry removed. The other 44 entries are byte-identical.
- The upstream tarball has the integrity `sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==`, the one the lockfile held before the override.
- The package is MIT licensed and carries its own `LICENSE`.
## Checking it
- `src/vendor-windows-names.test.ts` fails if the tarball holds a name Windows cannot create, so re-vendoring the unmodified upstream file is caught.
- To redo it: `npm pack [email protected]`, check the integrity above, drop the colon-named entry, write the archive back, then run `pnpm install --lockfile-only`.
## When to remove it
Delete the tarball, the `pnpm.overrides` entry and the test once `@a2ui/web_core` depends on a `redos-detector` that no longer ships a colon-named file, then run `pnpm install --lockfile-only`.
Binary file not shown.
+1 -1
View File
@@ -16,7 +16,7 @@ const ROUTES = [
"/appearance",
"/agent-presets", "/agent-composition",
"/events", "/history", "/status", "/balance", "/submit", "/cancel", "/approve", "/answer",
"/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents",
"/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents", "/processes",
"/plan", "/plan-decision", "/goal", "/resume", "/models", "/tool-approval-mode", "/workspace-trust", "/preset",
"/model", "/default-model", "/default-mode", "/effort", "/new", "/sessions", "/session/tail", "/delete-session", "/provider-setup",
"/tools", "/present-files",
+9 -1
View File
@@ -150,9 +150,10 @@ allow = ["Bash(go test:*)"] # never prompted
[peers]
# enabled = true # your sessions on this machine can message each other; unpaired messages wait for the receiving session's user to approve them; also a switch in Settings, applied at the next turn (user config only)
# dispatch = true # the model may hand a task to a session in another of your projects, via dispatch_to_project (the session it opens sends its final answer back on its own), and ask you to open folders as projects with open_projects (one card lists every path; nothing is added or trusted until you approve it, and no mode answers it for you); needs enabled; turning it on is your consent for those sessions' tasks and replies, no card, and a project in your sidebar you have not decided about is trusted by being named; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
# dispatch = true # the model may hand a task to a session in another of your projects, via dispatch_to_project (the session it opens sends its final answer back on its own), and ask you to open folders as projects with open_projects (one card lists every path; nothing is added or trusted until you approve it, and no mode answers it for you, except the open_nested switch below); needs enabled; turning it on is your consent for those sessions' tasks and replies, no card, and a project in your sidebar you have not decided about is trusted by being named; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
# max_dispatch_depth = 1 # how many sessions deep a dispatch may chain; 0 removes the ceiling (user config only)
# max_dispatched_sessions = 8 # sessions one session may keep open from its dispatches; 0 removes the ceiling (user config only)
# open_nested = false # open_projects adds folders inside the session's own workspace without a card; also a switch in Settings (user config only)
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply; they start over a pause the host set itself (session switch, unreadable @ reference, reopen) but never over one you pressed (user config only)
[mods]
@@ -2660,6 +2661,13 @@ For a folder not yet in your project list, add it yourself, or let the model
request `open_projects`; its card lists the paths and requires your explicit
approval even in YOLO mode before adding or trusting them.
One setting relaxes this:
- With the user-only `peers.open_nested = true` (also a switch in Settings), a call whose folders are all strictly inside the asking session's own workspace, after links are resolved, is added and trusted without the card, and a notice lists the paths.
- Any folder outside the workspace brings the card for the whole call.
- It is off by default, and a project's `reasonix.toml` cannot turn it on.
- A trusted folder's configuration can run programs, so turn it on only for a repository you trust.
`open_projects` is unavailable in plan mode until the plan is approved.
Dispatch refuses unknown or ambiguous projects and the current project itself
+9 -1
View File
@@ -159,9 +159,10 @@ allow = ["Bash(go test:*)"] # 从不询问
[peers]
# enabled = true # 本机你的各个会话可以互发消息;未配对会话的消息要对方会话的用户批准;设置里也有开关,下个回合起生效(只认用户级配置)
# dispatch = true # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话(对方做完会自己把最终答复送回来),也可用 open_projects 请你把文件夹开成项目(一张卡逐条列出路径,你批准前不会加入也不会被信任,任何模式都不会代你回答);需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
# dispatch = true # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话(对方做完会自己把最终答复送回来),也可用 open_projects 请你把文件夹开成项目(一张卡逐条列出路径,你批准前不会加入也不会被信任,任何模式都不会代你回答,下面的 open_nested 开关除外);需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
# max_dispatch_depth = 1 # 派发最多能连着开几层会话;0 表示不限(只认用户级配置)
# max_dispatched_sessions = 8 # 一个会话通过派发最多同时开着几个会话;0 表示不限(只认用户级配置)
# open_nested = false # open_projects 打开会话自己工作区之内的文件夹时不弹审批卡;设置里也有开关(只认用户级配置)
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧;宿主自己设的暂停(切换会话、@ 引用读不了、重新打开)不拦它们,你主动按下的暂停会拦住,消息留在队列里(只认用户级配置)
[mods]
@@ -1950,6 +1951,13 @@ dispatch = true
或让模型通过 `open_projects` 请求;其审批卡会列出路径,即使在 YOLO 模式下也必须
由你明确批准,才会添加或信任。
有一个设置可以放宽这一条:
- 用户级配置 `peers.open_nested = true`(设置里也有开关)开启后,一次调用里的文件夹只要全部严格位于发起会话自己的工作区之内(解析链接之后),就直接添加并信任,不弹卡,并用通知列出这些路径。
- 只要有一个文件夹在工作区之外,整次调用仍然弹卡。
- 默认关闭,项目里的 `reasonix.toml` 无法开启它。
- 被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。
计划模式下不能使用 `open_projects`,需要先批准计划。
不存在或重名不明确的项目、当前项目本身都会被拒绝(当前项目应在本地完成或用
+4
View File
@@ -758,6 +758,10 @@ type Messages struct {
// usage / help
UsageBody string // full multi-line help text
StandaloneConsoleHint string
// MinttyConsoleHint is shown instead of the usage text when stdin or stdout is a Cygwin or MSYS terminal, which a Windows program cannot drive as a console.
MinttyConsoleHint string
// OpenedNestedWithoutAskingFmt is the notice after open_projects added folders inside the workspace on the user's standing setting; the arguments are a count and the paths.
OpenedNestedWithoutAskingFmt string
// Feedback is the /feedback list standing and refusal wording.
Feedback FeedbackText
+3 -1
View File
@@ -743,7 +743,9 @@ var English = Messages{
CLITelemetryConsentSaveFailedFmt: "CLI telemetry remains disabled because the preference could not be saved: %v",
CLITelemetryConsentCleanupFailedFmt: "CLI telemetry is disabled, but pending statistics could not be deleted: %v",
StandaloneConsoleHint: "This is the Reasonix command-line tool; run it from a terminal such as PowerShell.\nFor the desktop app, install Reasonix Studio (ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\nPress Enter to close this window.\n",
MinttyConsoleHint: "The Reasonix terminal UI needs a Windows console, and this terminal (Git Bash / mintty) is not one.\nRun it from cmd, PowerShell or Windows Terminal. `reasonix run \"task\"` still works from here.\n",
OpenedNestedWithoutAskingFmt: "Opened %d folder(s) inside this project without asking, as the setting allows (Settings > Agent behavior > Messages between sessions): %s",
StandaloneConsoleHint: "This is the Reasonix command-line tool; run it from a terminal such as PowerShell.\nFor the desktop app, install Reasonix Studio (ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\nPress Enter to close this window.\n",
UsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)
Usage:
+3 -1
View File
@@ -743,7 +743,9 @@ var Chinese = Messages{
CLITelemetryConsentSaveFailedFmt: "由于无法保存偏好设置,CLI 使用统计仍保持关闭:%v",
CLITelemetryConsentCleanupFailedFmt: "CLI 使用统计已关闭,但无法删除待发送统计:%v",
StandaloneConsoleHint: "这是 Reasonix 命令行工具,需要在 PowerShell 等终端里运行。\n要用桌面应用,请安装 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按回车关闭此窗口。\n",
MinttyConsoleHint: "Reasonix 的终端界面需要 Windows 控制台,而当前终端(Git Bash / mintty)不是。\n请在 cmd、PowerShell 或 Windows Terminal 里运行;在这里仍可用 `reasonix run \"任务\"` 执行单个任务。\n",
OpenedNestedWithoutAskingFmt: "按设置(设置 > 智能体行为 > 会话间消息)的允许,已在不询问的情况下打开本项目内的 %d 个文件夹:%s",
StandaloneConsoleHint: "这是 Reasonix 命令行工具,需要在 PowerShell 等终端里运行。\n要用桌面应用,请安装 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按回车关闭此窗口。\n",
UsageBody: `reasonix — 由配置和插件驱动的 coding agent(多模型)
用法:
+3 -1
View File
@@ -666,7 +666,9 @@ var ChineseTraditional = Messages{
CLITelemetryConsentSaveFailedFmt: "由於無法儲存偏好設定,CLI 使用統計仍維持關閉:%v",
CLITelemetryConsentCleanupFailedFmt: "CLI 使用統計已關閉,但無法刪除待傳送統計:%v",
StandaloneConsoleHint: "這是 Reasonix 命令列工具,需要在 PowerShell 等終端機裡執行。\n要使用桌面應用程式,請安裝 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按 Enter 關閉此視窗。\n",
MinttyConsoleHint: "Reasonix 的終端介面需要 Windows 主控台,而目前的終端機(Git Bash / mintty)不是。\n請在 cmd、PowerShell 或 Windows Terminal 裡執行;在這裡仍可用 `reasonix run \"任務\"` 執行單一任務。\n",
OpenedNestedWithoutAskingFmt: "依設定(設定 > 智慧代理行為 > 會話間訊息)的允許,已在不詢問的情況下開啟本專案內的 %d 個資料夾:%s",
StandaloneConsoleHint: "這是 Reasonix 命令列工具,需要在 PowerShell 等終端機裡執行。\n要使用桌面應用程式,請安裝 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按 Enter 關閉此視窗。\n",
UsageBody: `reasonix — 由設定和插件驅動的 coding agent(多模型)
用法:
+39
View File
@@ -0,0 +1,39 @@
package agentgraph
import "reasonix/internal/contract/process"
// ProcessClass projects the node state onto the shared process vocabulary. A
// queued node carries the cause the scheduler held it for.
func (s NodeState) ProcessClass(w WaitCause) (process.Class, bool) {
switch s {
case StatePending:
return process.Class{State: process.Pending}, true
case StateQueued:
return process.Class{State: process.Queued, Cause: waitCause(w)}, true
case StateRunning:
return process.Class{State: process.Running}, true
case StateCompleted:
return process.Class{State: process.Completed}, true
case StateAdopted:
return process.Class{State: process.Completed, Cause: process.CauseAdopted}, true
case StateFailed:
return process.Class{State: process.Failed}, true
case StateCancelled:
return process.Class{State: process.Stopped}, true
case StateSkipped:
return process.Class{State: process.Skipped}, true
}
return process.Class{}, false
}
func waitCause(w WaitCause) process.Cause {
switch w {
case WaitSlots:
return process.CauseSlots
case WaitWriters:
return process.CauseWriters
case WaitClaim:
return process.CauseClaim
}
return process.CauseNone
}
@@ -0,0 +1,21 @@
package agentgraph
import (
"testing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/process/proctest"
)
func TestNodeStateMapsOntoProcessStates(t *testing.T) {
proctest.Total(t, ".", "NodeState", func(v string) (process.Class, bool) { return NodeState(v).ProcessClass("") })
}
func TestQueuedNodeCarriesItsWaitCause(t *testing.T) {
for w, want := range map[WaitCause]process.Cause{WaitSlots: process.CauseSlots, WaitWriters: process.CauseWriters, WaitClaim: process.CauseClaim} {
got, _ := StateQueued.ProcessClass(w)
if got.State != process.Queued || got.Cause != want {
t.Errorf("queued for %q: got %+v", w, got)
}
}
}
+12 -1
View File
@@ -27,6 +27,10 @@ type PeersConfig struct {
// dispatches at once, 0 for no ceiling. Each is a kernel and a lease, so it
// bounds a loop that keeps opening panes. User file only.
MaxDispatchedSessions int `toml:"max_dispatched_sessions"`
// OpenNested lets open_projects add folders inside the asking session's own
// workspace without the approval card. A folder outside it still asks. Off
// unless the user turns it on; user file only.
OpenNested bool `toml:"open_nested"`
}
func defaultPeers() PeersConfig {
@@ -46,6 +50,10 @@ func (c *Config) PeersEnabled() bool { return c != nil && c.Peers.Enabled }
// PeersDispatch reports whether the model may dispatch a task to another project.
func (c *Config) PeersDispatch() bool { return c.PeersEnabled() && c.Peers.Dispatch }
// PeersOpenNested reports whether open_projects may add folders inside the
// asking session's workspace without a card.
func (c *Config) PeersOpenNested() bool { return c != nil && c.Peers.OpenNested }
// PeersMaxDispatchDepth is the user's depth ceiling for chained dispatches, 0 for none.
func (c *Config) PeersMaxDispatchDepth() int {
if c == nil {
@@ -91,7 +99,7 @@ func renderServeAndPeers(b *strings.Builder, c *Config) {
func renderPeersSection(b *strings.Builder, c *Config) {
p := c.Peers
customCeilings := p.MaxDispatchDepth != DefaultMaxDispatchDepth || p.MaxDispatchedSessions != DefaultMaxDispatchedSessions
if p.Enabled && p.Dispatch && len(p.ReleaseProjects) == 0 && !customCeilings {
if p.Enabled && p.Dispatch && len(p.ReleaseProjects) == 0 && !customCeilings && !p.OpenNested {
return
}
b.WriteString("[peers]\n")
@@ -100,6 +108,9 @@ func renderPeersSection(b *strings.Builder, c *Config) {
if len(p.ReleaseProjects) > 0 {
fmt.Fprintf(b, "release_projects = %s # projects that take messages from your other sessions without a card\n", renderStringArray(p.ReleaseProjects))
}
if p.OpenNested {
b.WriteString("open_nested = true # open_projects adds folders inside the asking session's workspace without a card; user file only\n")
}
if customCeilings {
fmt.Fprintf(b, "max_dispatch_depth = %d # how many sessions deep a dispatch may chain; 0 for no ceiling\n", p.MaxDispatchDepth)
fmt.Fprintf(b, "max_dispatched_sessions = %d # sessions one session may keep open from its dispatches; 0 for no ceiling\n", p.MaxDispatchedSessions)
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/base/testenv"
@@ -170,3 +171,53 @@ func TestCeilingsTheUserSetSurviveAnUnrelatedSave(t *testing.T) {
t.Fatalf("ceilings after a save = %d, %d; want 0 and 3", got.PeersMaxDispatchDepth(), got.PeersMaxDispatchedSessions())
}
}
func TestOpeningNestedFoldersWithoutAskingIsOffUnlessTheUserTurnsItOn(t *testing.T) {
if Default().PeersOpenNested() {
t.Fatal("opening folders without a card is on by default")
}
cfg, _ := loadScoped(t, "[peers]\nopen_nested = true\n", "")
if !cfg.PeersOpenNested() {
t.Fatal("the user's own open_nested was not read")
}
}
// The setting skips a card that decides whether a folder is trusted, so a cloned
// repository must neither switch it on nor undo the user's choice.
func TestAProjectFileCannotTurnOpeningFoldersWithoutAskingOn(t *testing.T) {
cfg, _ := loadScoped(t, "", "[peers]\nopen_nested = true\n")
if cfg.PeersOpenNested() {
t.Fatal("a cloned repo's reasonix.toml let folders open without a card")
}
if !slices.Contains(ignoredKeys(cfg), "peers.open_nested") {
t.Fatalf("ignored = %v, want peers.open_nested reported", ignoredKeys(cfg))
}
if kept, _ := loadScoped(t, "[peers]\nopen_nested = true\n", "[peers]\nopen_nested = false\n"); !kept.PeersOpenNested() {
t.Fatal("the project file overrode the user's own choice")
}
}
func TestSavingTheUserFileKeepsOpenNestedAndWritesNothingWhenItIsOff(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
cfg := LoadForEdit(path)
cfg.Peers.OpenNested = true
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
if !LoadForEdit(path).Peers.OpenNested {
t.Fatal("open_nested did not survive a save")
}
off := LoadForEdit(path)
off.Peers.OpenNested = false
if err := off.SaveTo(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "open_nested") {
t.Fatalf("an off setting was written out:\n%s", raw)
}
}
+3
View File
@@ -29,5 +29,8 @@ func (h heldScope) narrowPeers(c *Config) {
if c.Peers.MaxDispatchedSessions != h.peers.MaxDispatchedSessions {
c.ignoreProject("peers.max_dispatched_sessions", fmt.Sprint(c.Peers.MaxDispatchedSessions), ProjectUserOnly)
}
if c.Peers.OpenNested != h.peers.OpenNested {
c.ignoreProject("peers.open_nested", fmt.Sprintf("%t", c.Peers.OpenNested), ProjectUserOnly)
}
c.Peers = clonePeers(h.peers)
}
+7
View File
@@ -0,0 +1,7 @@
// Package process is the shared vocabulary for work the kernel is running: a
// closed set of states and the typed causes that explain them.
//
// It records nothing and owns no lifecycle. Each owner keeps its own states and
// transitions and maps them onto this set; a layer above may project a class,
// never re-derive one. A cause is left empty when the owner does not know it.
package process
+81
View File
@@ -0,0 +1,81 @@
package process
import (
"slices"
"time"
)
// State is where a unit of work stands, in terms every owner can be mapped onto.
type State string
const (
Pending State = "pending"
Queued State = "queued"
Running State = "running"
Waiting State = "waiting"
Completed State = "completed"
Failed State = "failed"
Stopped State = "stopped"
Interrupted State = "interrupted"
Skipped State = "skipped"
)
// Cause says why a unit is in its State. Empty means the owner does not record
// one, which is not the same as there being none.
type Cause string
const (
CauseNone Cause = ""
CauseSlots Cause = "slots"
CauseWriters Cause = "writers"
CauseClaim Cause = "claim"
CauseHeld Cause = "held"
CauseAdopted Cause = "adopted"
CauseBudget Cause = "budget"
CauseParked Cause = "parked"
)
// Class is one owner state projected onto the shared vocabulary.
type Class struct {
State State
Cause Cause
}
// States lists every State.
var States = []State{Pending, Queued, Running, Waiting, Completed, Failed, Stopped, Interrupted, Skipped}
// Terminal reports whether a unit in this state will not change state again.
func (s State) Terminal() bool {
switch s {
case Completed, Failed, Stopped, Interrupted, Skipped:
return true
}
return false
}
// Valid reports whether s is one of States.
func (s State) Valid() bool { return slices.Contains(States, s) }
// Kind is what sort of work a Process is. It is the owner that produced it.
type Kind string
const (
KindSubagent Kind = "subagent"
KindJob Kind = "job"
)
// Process is one running unit as a reader sees it.
type Process struct {
// ID is "<kind>:<owner id>", stable for as long as the owner keeps the unit.
ID string
Kind Kind
// Parent is the ID of the process that started this one; empty for a root.
Parent string
Name string
State State
Cause Cause
Started time.Time
}
// ID builds the process id for an owner's own id.
func ID(k Kind, owner string) string { return string(k) + ":" + owner }
+34
View File
@@ -0,0 +1,34 @@
package process
import (
"strings"
"testing"
)
func TestTerminalStatesAreExactlyThoseThatEnd(t *testing.T) {
for _, s := range States {
live := s == Pending || s == Queued || s == Running || s == Waiting
if s.Terminal() == live {
t.Errorf("%s: terminal=%v", s, s.Terminal())
}
if !s.Valid() || strings.TrimSpace(string(s)) != string(s) {
t.Errorf("%q is not a clean valid state", s)
}
}
if State("paused").Valid() {
t.Error("an unknown state must not be valid")
}
}
func BenchmarkStateTerminal(b *testing.B) {
b.ReportAllocs()
for i := range b.N {
_ = States[i%len(States)].Terminal()
}
}
func TestIDNamesTheKindAndTheOwnersOwnID(t *testing.T) {
if got := ID(KindJob, "j1"); got != "job:j1" {
t.Fatalf("ID = %q, want job:j1", got)
}
}
@@ -0,0 +1,71 @@
// Package proctest holds the check that keeps an owner's mapping onto the
// process vocabulary total.
package proctest
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"reasonix/internal/contract/process"
)
// Declared returns the string values of every constant of typeName declared in
// the non-test files of dir.
func Declared(t testing.TB, dir, typeName string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
fset := token.NewFileSet()
var out []string
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
for _, sp := range gd.Specs {
vs := sp.(*ast.ValueSpec)
id, ok := vs.Type.(*ast.Ident)
if !ok || id.Name != typeName || len(vs.Values) != 1 {
continue
}
if lit, ok := vs.Values[0].(*ast.BasicLit); ok && lit.Kind == token.STRING {
v, _ := strconv.Unquote(lit.Value)
out = append(out, v)
}
}
}
}
return out
}
// Total fails unless every declared value of typeName maps to a valid state.
func Total(t testing.TB, dir, typeName string, class func(string) (process.Class, bool)) {
t.Helper()
vals := Declared(t, dir, typeName)
if len(vals) == 0 {
t.Fatalf("no constants of %s found in %s", typeName, dir)
}
for _, v := range vals {
c, ok := class(v)
if !ok || !c.State.Valid() {
t.Errorf("%s %q has no mapping onto the process vocabulary", typeName, v)
}
}
}
+1
View File
@@ -261,5 +261,6 @@ var table = []Entry{
{ID: "peers.release_projects", Class: Guarded, Effect: NextTurn},
{ID: "peers.max_dispatch_depth", Class: Guarded, Effect: NextTurn},
{ID: "peers.max_dispatched_sessions", Class: Guarded, Effect: NextTurn},
{ID: "peers.open_nested", Class: Guarded, Effect: Immediate},
{ID: "storage", Class: Guarded, Effect: Restart},
}
@@ -281,3 +281,13 @@ func TestTheDesktopScreenTableNamesOnlyDeclaredSettings(t *testing.T) {
}
}
}
func TestSkippingTheOpenFoldersCardIsNeverTheModelsToWrite(t *testing.T) {
e, ok := Lookup("peers.open_nested")
if !ok {
t.Fatal("peers.open_nested is not declared")
}
if e.Class != Guarded {
t.Fatalf("class = %v, want Guarded: a setting that removes a trust card must not be written on the model's say-so", e.Class)
}
}
+11
View File
@@ -24,3 +24,14 @@ func bareUsage(bare bool) int {
}
return 2
}
// bareOutsideConsole answers a bare invocation that has no console: a Cygwin
// or MSYS terminal is told why the UI cannot run there, anything else gets the
// usage text.
func bareOutsideConsole(bare bool) int {
if hint := minttyHint(os.Stdin, os.Stdout, onCygwinPty); hint != "" {
fmt.Fprint(os.Stderr, hint)
return 2
}
return bareUsage(bare)
}
+1 -1
View File
@@ -83,7 +83,7 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) {
return runTUI(args, version)
}
return bareUsage(len(args) == 0)
return bareOutsideConsole(len(args) == 0)
}
rest := args[1:]
+25
View File
@@ -0,0 +1,25 @@
package cli
import (
"os"
"regexp"
"reasonix/internal/base/i18n"
)
// A Cygwin or MSYS terminal, Git Bash's mintty among them, hands a Windows
// program named pipes instead of a console, so no console call works on it.
// The runtime names them this way; MSYS2 adds "-nat" to those it opens for
// native programs.
var cygwinPty = regexp.MustCompile(`^\\(?:cygwin|msys)-[0-9a-f]{16}-pty[0-9]+-(?:from|to)-master(?:-nat)?$`)
func cygwinPtyName(name string) bool { return cygwinPty.MatchString(name) }
// minttyHint is what to say instead of the usage text when either end of the
// session is a Cygwin or MSYS terminal, and "" when neither is.
func minttyHint(in, out *os.File, on func(*os.File) bool) string {
if on(in) || on(out) {
return i18n.M.MinttyConsoleHint
}
return ""
}
@@ -0,0 +1,7 @@
//go:build !windows
package cli
import "os"
func onCygwinPty(*os.File) bool { return false }
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"os"
"testing"
"reasonix/internal/base/i18n"
"reasonix/internal/base/testenv"
)
func TestCygwinPtyNamesAreTheOnesMSYSAndCygwinGiveTheirTerminals(t *testing.T) {
for name, want := range map[string]bool{
`\msys-1888ae32e00d56aa-pty0-from-master`: true,
`\msys-1888ae32e00d56aa-pty0-to-master`: true,
`\cygwin-e022582115c10879-pty4-to-master`: true,
`\cygwin-e022582115c10879-pty4-from-master`: true,
`\msys-1888ae32e00d56aa-pty2-from-master-nat`: true,
`\msys-1888ae32e00d56aa-pty2-to-master-nat`: true,
`\msys-1888ae32e00d56aa-pty2-to-master-nat2`: false,
`\msys-1888ae32e00d56aa-pty0-master`: false,
`\msys-1888ae32e00d56a-pty0-to-master`: false,
`\Device\NamedPipe\mojo.1234`: false,
`\pipe\something`: false,
``: false,
} {
if got := cygwinPtyName(name); got != want {
t.Errorf("cygwinPtyName(%q) = %v, want %v", name, got, want)
}
}
}
func TestAMinttyEndReplacesTheUsageTextWithTheHintAndAConsoleDoesNot(t *testing.T) {
in, out := os.Stdin, os.Stdout
if got := minttyHint(in, out, func(f *os.File) bool { return f == out }); got != i18n.M.MinttyConsoleHint || got == "" {
t.Fatalf("hint = %q when stdout is a pty", got)
}
if got := minttyHint(in, out, func(f *os.File) bool { return f == in }); got == "" {
t.Fatal("no hint when stdin is a pty")
}
if got := minttyHint(in, out, func(*os.File) bool { return false }); got != "" {
t.Fatalf("hint %q for a real console or a pipe", got)
}
}
func TestAskingAboutAFileOrAPipeIsAnAnswerNotAFault(t *testing.T) {
f, err := os.CreateTemp(testenv.TempDir(t), "x")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if onCygwinPty(f) {
t.Fatal("a regular file reported as a Cygwin terminal")
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
if onCygwinPty(r) || onCygwinPty(w) {
t.Fatal("an anonymous pipe reported as a Cygwin terminal")
}
}
@@ -0,0 +1,31 @@
//go:build windows
package cli
import (
"os"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
const fileNameInfo = 2
// onCygwinPty reports whether f is one end of a Cygwin or MSYS terminal.
func onCygwinPty(f *os.File) bool {
h := windows.Handle(f.Fd())
if t, err := windows.GetFileType(h); err != nil || t != windows.FILE_TYPE_PIPE {
return false
}
var buf [4 + 2*windows.MAX_PATH]byte
if windows.GetFileInformationByHandleEx(h, fileNameInfo, &buf[0], uint32(len(buf))) != nil {
return false
}
n := *(*uint32)(unsafe.Pointer(&buf[0])) / 2
if n == 0 || int(n) > windows.MAX_PATH {
return false
}
name := unsafe.Slice((*uint16)(unsafe.Pointer(&buf[4])), n)
return cygwinPtyName(string(utf16.Decode(name)))
}
+4
View File
@@ -36,6 +36,10 @@ func runTUI(args []string, version string) int {
return code
}
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
if hint := minttyHint(os.Stdin, os.Stdout, onCygwinPty); hint != "" {
fmt.Fprint(os.Stderr, hint)
return 2
}
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "reasonix tui needs an interactive terminal; use `reasonix run` for scripts")
return 2
}
+3 -2
View File
@@ -51,13 +51,14 @@ func (s *Server) savePeersSettings(w http.ResponseWriter, r *http.Request) {
ReleaseProjects *[]string `json:"release_projects"`
MaxDepth *int `json:"max_dispatch_depth"`
MaxSessions *int `json:"max_dispatched_sessions"`
OpenNested *bool `json:"open_nested"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil ||
(body.Enabled == nil && body.Dispatch == nil && body.ReleaseProjects == nil && body.MaxDepth == nil && body.MaxSessions == nil) {
(body.Enabled == nil && body.Dispatch == nil && body.ReleaseProjects == nil && body.MaxDepth == nil && body.MaxSessions == nil && body.OpenNested == nil) {
badBody(w)
return
}
got, err := s.ctl().SavePeersSettings(control.PeersPatch{Enabled: body.Enabled, Dispatch: body.Dispatch, ReleaseProjects: body.ReleaseProjects, MaxDispatchDepth: body.MaxDepth, MaxDispatchedSessions: body.MaxSessions})
got, err := s.ctl().SavePeersSettings(control.PeersPatch{Enabled: body.Enabled, Dispatch: body.Dispatch, ReleaseProjects: body.ReleaseProjects, MaxDispatchDepth: body.MaxDepth, MaxDispatchedSessions: body.MaxSessions, OpenNested: body.OpenNested})
if err != nil {
refusePeer(w, err)
return
@@ -109,3 +109,23 @@ func TestANetworkedClientCannotAllowAProjectThrough(t *testing.T) {
t.Fatalf("the refused save still wrote the user file: %v", got)
}
}
func TestOpeningFoldersWithoutAskingRoundTripsAndANetworkedClientCannotTurnItOn(t *testing.T) {
srv := standbyServer(t, true)
if getSettings(t, srv.URL).OpenNested {
t.Fatal("on by default")
}
if status, raw := postSide(t, srv.URL+"/peers/settings", map[string]any{"open_nested": true}); status != http.StatusOK {
t.Fatalf("save = %d %s", status, raw)
}
if !getSettings(t, srv.URL).OpenNested || !config.UserPeers().OpenNested {
t.Fatal("open_nested was not saved")
}
locked := standbyServer(t, false)
if status, raw := postSide(t, locked.URL+"/peers/settings", map[string]any{"open_nested": true}); status != http.StatusForbidden {
t.Fatalf("save without the grant = %d %s", status, raw)
}
if config.UserPeers().OpenNested {
t.Fatal("the refused save still wrote the user file")
}
}
+44
View File
@@ -0,0 +1,44 @@
package serve
import (
"net/http"
"reasonix/internal/contract/process"
)
// registerRunningWorkRoutes serves what the session has handed off or has
// running: its sub-agent runs and the process list across owners.
func (s *Server) registerRunningWorkRoutes(mux *http.ServeMux) {
s.registerSubagentRoutes(mux)
mux.HandleFunc("GET /processes", s.processes)
}
type processView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Parent string `json:"parent,omitempty"`
Name string `json:"name"`
State string `json:"state"`
Cause string `json:"cause,omitempty"`
StartedAt int64 `json:"startedAt"` // unix milliseconds
}
func processOf(p process.Process) processView {
return processView{
ID: p.ID, Kind: string(p.Kind), Parent: p.Parent, Name: p.Name,
State: string(p.State), Cause: string(p.Cause), StartedAt: p.Started.UnixMilli(),
}
}
func (s *Server) processes(w http.ResponseWriter, _ *http.Request) {
procs, err := s.ctl().Processes()
if err != nil {
refuse(w, http.StatusInternalServerError, "process.read_failed", err.Error(), map[string]any{"detail": err.Error()})
return
}
out := make([]processView, 0, len(procs))
for _, p := range procs {
out = append(out, processOf(p))
}
writeJSON(w, out)
}
+124
View File
@@ -0,0 +1,124 @@
package serve
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/process"
"reasonix/internal/session/control"
"reasonix/internal/tools/jobs"
)
type processSource struct {
control.SessionAPI
procs []process.Process
err error
calls int
}
func (c *processSource) Processes() ([]process.Process, error) {
c.calls++
return c.procs, c.err
}
func processMux(src *processSource) *http.ServeMux {
mux := http.NewServeMux()
(&Server{ctrl: src}).registerRunningWorkRoutes(mux)
return mux
}
func TestProcessesAreListedWithStateAndCause(t *testing.T) {
started := time.UnixMilli(1_760_000_000_000)
src := &processSource{procs: []process.Process{
{ID: "subagent:sa_1", Kind: process.KindSubagent, Parent: "turn:t3", Name: "audit", State: process.Queued, Cause: process.CauseWriters, Started: started},
{ID: "job:bash-1", Kind: process.KindJob, Name: "sleep 60", State: process.Running, Started: started},
}}
rec := get(processMux(src), "/processes")
var got []processView
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || rec.Code != 200 {
t.Fatalf("status %d body %s err %v", rec.Code, rec.Body.String(), err)
}
if len(got) != 2 || got[0].ID != "subagent:sa_1" || got[0].State != "queued" || got[0].Cause != "writers" || got[0].Parent != "turn:t3" || got[0].StartedAt != 1_760_000_000_000 {
t.Fatalf("processes = %+v", got)
}
if got[1].Cause != "" || got[1].Parent != "" || got[1].Kind != "job" {
t.Fatalf("a job has no parent and no cause, got %+v", got[1])
}
}
func TestNoProcessesIsAnEmptyListAndNothingIsReadUnasked(t *testing.T) {
src := &processSource{}
mux := processMux(src)
if src.calls != 0 {
t.Fatalf("registering the route read the process table %d times", src.calls)
}
if rec := get(mux, "/processes"); rec.Body.String() != "[]\n" {
t.Fatalf("no processes must be an empty list, got %q", rec.Body.String())
}
if src.calls != 1 {
t.Fatalf("one request read the table %d times, want 1", src.calls)
}
}
func TestAFailedReadIsACodedRefusalNotAnEmptyList(t *testing.T) {
rec := get(processMux(&processSource{err: errors.New("disk gone")}), "/processes")
var reason Reason
if err := json.Unmarshal(rec.Body.Bytes(), &reason); err != nil || rec.Code != http.StatusInternalServerError || reason.Code != "process.read_failed" {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
}
func TestARealControllerReportsItsRunningJobOverTheRoute(t *testing.T) {
m := jobs.NewManager(event.Discard)
t.Cleanup(m.Close)
job := m.Start("bash", "sleep 60", func(ctx context.Context, _ io.Writer) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
ctrl := control.New(control.Options{Sink: event.Discard, Jobs: m})
mux := http.NewServeMux()
(&Server{ctrl: ctrl}).registerRunningWorkRoutes(mux)
var got []processView
rec := get(mux, "/processes")
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || rec.Code != 200 {
t.Fatalf("status %d body %s err %v", rec.Code, rec.Body.String(), err)
}
if len(got) != 1 || got[0].ID != "job:"+job.ID || got[0].State != "running" || got[0].Name != "sleep 60" || got[0].StartedAt == 0 {
t.Fatalf("processes = %+v, want the running job", got)
}
m.Kill(job.ID)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if rec := get(mux, "/processes"); rec.Body.String() == "[]\n" {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("a killed job is still listed as running")
}
func BenchmarkProcessesRoute(b *testing.B) {
m := jobs.NewManager(event.Discard)
b.Cleanup(m.Close)
for range 20 {
m.Start("bash", "sleep 60", func(ctx context.Context, _ io.Writer) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
}
mux := http.NewServeMux()
(&Server{ctrl: control.New(control.Options{Sink: event.Discard, Jobs: m})}).registerRunningWorkRoutes(mux)
b.ReportAllocs()
for range b.N {
if rec := get(mux, "/processes"); rec.Code != 200 {
b.Fatalf("status %d", rec.Code)
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (s *Server) routes() http.Handler {
s.registerGitRoutes(mux)
s.registerJobRoutes(mux)
s.registerToolCallRoutes(mux)
s.registerSubagentRoutes(mux)
s.registerRunningWorkRoutes(mux)
s.registerProviderRoutes(mux)
s.registerRoleRoutes(mux)
s.registerWelcomeRoutes(mux)
+9 -6
View File
@@ -13,12 +13,13 @@ import (
)
const (
codeTerminalNotFound = "terminal.not_found"
codeTerminalShell = "terminal.shell_missing"
codeTerminalStart = "terminal.start_failed"
codeTerminalExited = "terminal.exited"
codeTerminalLimit = "terminal.limit"
terminalInputLimit = 1 << 16
codeTerminalNotFound = "terminal.not_found"
codeTerminalShell = "terminal.shell_missing"
codeTerminalStart = "terminal.start_failed"
codeTerminalUnsupported = "terminal.unsupported"
codeTerminalExited = "terminal.exited"
codeTerminalLimit = "terminal.limit"
terminalInputLimit = 1 << 16
)
// registerWorkbenchRoutes is what the panes beside the conversation call on.
@@ -49,6 +50,8 @@ func refuseTerminal(w http.ResponseWriter, err error) {
refuse(w, http.StatusConflict, codeTerminalExited, err.Error(), nil)
case terminal.CodeLimit:
refuse(w, http.StatusConflict, codeTerminalLimit, err.Error(), nil)
case terminal.CodeUnsupported:
refuse(w, http.StatusNotImplemented, codeTerminalUnsupported, err.Error(), nil)
default:
refuse(w, http.StatusInternalServerError, codeTerminalStart, err.Error(), nil)
}
@@ -0,0 +1,19 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"reasonix/internal/platform/terminal"
)
func TestAnUnsupportedTerminalIsRefusedWithItsOwnCode(t *testing.T) {
rec := httptest.NewRecorder()
refuseTerminal(rec, &terminal.Error{Code: terminal.CodeUnsupported, Detail: "no pseudoconsole"})
var reason Reason
if err := json.Unmarshal(rec.Body.Bytes(), &reason); err != nil || rec.Code != http.StatusNotImplemented || reason.Code != "terminal.unsupported" {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
}
+1
View File
@@ -10,6 +10,7 @@ const (
CodeNotFound = "terminal.not_found"
CodeShellMissing = "terminal.shell_missing"
CodeStartFailed = "terminal.start_failed"
CodeUnsupported = "terminal.unsupported"
CodeExited = "terminal.exited"
CodeLimit = "terminal.limit"
)
+12 -1
View File
@@ -4,6 +4,7 @@ package terminal
import (
"context"
"errors"
"strings"
"sync"
"syscall"
@@ -31,11 +32,21 @@ func startPty(o Options) (pty, error) {
}
c, err := conpty.Start(strings.Join(parts, " "), opts...)
if err != nil {
return nil, fail(CodeStartFailed, "%v", err)
return nil, ptyStartError(err)
}
return &winPty{c: c}, nil
}
// ptyStartError separates a Windows that has no pseudoconsole (before 1809)
// from a shell that failed to start: the first can never succeed, so a person
// is told the terminal is unsupported rather than to try again.
func ptyStartError(err error) *Error {
if errors.Is(err, conpty.ErrConPtyUnsupported) {
return fail(CodeUnsupported, "%v", err)
}
return fail(CodeStartFailed, "%v", err)
}
func (p *winPty) Read(b []byte) (int, error) { return p.c.Read(b) }
func (p *winPty) Write(b []byte) (int, error) { return p.c.Write(b) }
func (p *winPty) Close() error {
@@ -0,0 +1,24 @@
//go:build windows
package terminal
import (
"errors"
"fmt"
"testing"
"github.com/UserExistsError/conpty"
)
func TestAWindowsWithoutAPseudoConsoleIsAnUnsupportedTerminalNotAFailedStart(t *testing.T) {
err := ptyStartError(fmt.Errorf("start: %w", conpty.ErrConPtyUnsupported))
if CodeOf(err) != CodeUnsupported {
t.Fatalf("code = %q, want %q", CodeOf(err), CodeUnsupported)
}
}
func TestAnyOtherStartFailureStaysAFailedStart(t *testing.T) {
if got := CodeOf(ptyStartError(errors.New("the file cannot be found"))); got != CodeStartFailed {
t.Fatalf("code = %q, want %q", got, CodeStartFailed)
}
}
@@ -0,0 +1,151 @@
package delegation
import (
"context"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/tool"
)
func TestTurnIdentityRidesTheContext(t *testing.T) {
if got := TurnIdentity(context.Background()); got != "" {
t.Fatalf("bare context carries turn %q", got)
}
if got := TurnIdentity(WithTurnIdentity(context.Background(), "turn-7")); got != "turn-7" {
t.Fatalf("TurnIdentity = %q, want turn-7", got)
}
}
func TestRunRecordNamesTheTurnItWasSpawnedIn(t *testing.T) {
store := NewSubagentStore(testenv.TempDir(t))
spec := testSubagentSpec(t, "audit")
spec.ParentTurn = "turn-7"
run, err := store.PrepareFresh(spec)
if err != nil {
t.Fatalf("PrepareFresh: %v", err)
}
defer run.Release()
if err := store.MarkRunning(run); err != nil {
t.Fatalf("MarkRunning: %v", err)
}
meta, err := store.LoadMeta(run.Ref)
if err != nil || meta.ParentTurn != "turn-7" {
t.Fatalf("stored parent turn = %q err=%v, want turn-7", meta.ParentTurn, err)
}
got, err := store.Transcript(run.Ref, spec.ParentSession, spec.WorkspaceRoot, 0, 0)
if err != nil || got.Info.ParentTurn != "turn-7" {
t.Fatalf("listed parent turn = %q err=%v, want turn-7", got.Info.ParentTurn, err)
}
}
func TestParentTurnDoesNotChangeTheCapsuleIdentity(t *testing.T) {
store := NewSubagentStore(testenv.TempDir(t))
plain := testSubagentSpec(t, "audit")
turned := plain
turned.ParentTurn = "turn-9"
a, err := store.PrepareFresh(plain)
if err != nil {
t.Fatal(err)
}
defer a.Release()
b, err := store.PrepareFresh(turned)
if err != nil {
t.Fatal(err)
}
defer b.Release()
if a.Meta.CapsuleHash != b.Meta.CapsuleHash {
t.Fatal("a parent turn changed the capsule hash: two runs of the same work would differ on every turn")
}
}
func BenchmarkPrepareFresh(b *testing.B) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
for _, turn := range []string{"", "turn-7"} {
b.Run("turn="+turn, func(b *testing.B) {
store := NewSubagentStore(testenv.TempDir(b))
spec := SubagentSpec{ExecutionID: "e", Kind: "task", Name: "x", WorkspaceRoot: testenv.TempDir(b), ParentSession: "p", SystemPrompt: "s", Registry: reg, ParentTurn: turn}
b.ReportAllocs()
for range b.N {
run, err := store.PrepareFresh(spec)
if err != nil {
b.Fatal(err)
}
run.Release()
}
})
}
}
func TestLiveRunsListsOnlyWhatThisProcessIsExecuting(t *testing.T) {
store, spec := viewFixture(t)
spec.ParentTurn = "turn-3"
running := startedRun(t, store, spec)
done := startedRun(t, store, spec)
if err := store.SaveCompleted(done); err != nil {
t.Fatal(err)
}
done.Release()
got, err := store.LiveRuns(spec.ParentSession, spec.WorkspaceRoot)
if err != nil {
t.Fatalf("LiveRuns: %v", err)
}
if len(got) != 1 || got[0].Ref != running.Ref || got[0].State != RunRunning || got[0].ParentTurn != "turn-3" {
t.Fatalf("live runs = %+v, want only %s running under turn-3", got, running.Ref)
}
if other, _ := store.LiveRuns("another-session", spec.WorkspaceRoot); len(other) != 0 {
t.Fatalf("another conversation sees %d live runs, want none", len(other))
}
running.Release()
if after, _ := store.LiveRuns(spec.ParentSession, spec.WorkspaceRoot); len(after) != 0 {
t.Fatalf("a released run is still live: %+v", after)
}
}
func BenchmarkLiveRunsAgainstARunsScan(b *testing.B) {
store := NewSubagentStore(testenv.TempDir(b) + "/subagents")
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
spec := SubagentSpec{ExecutionID: "e", Kind: "task", Name: "x", WorkspaceRoot: testenv.TempDir(b), ParentSession: "p", SystemPrompt: "s", Registry: reg}
for range 300 {
run, err := store.PrepareFresh(spec)
if err != nil {
b.Fatal(err)
}
if err := store.MarkRunning(run); err != nil {
b.Fatal(err)
}
if err := store.SaveCompleted(run); err != nil {
b.Fatal(err)
}
run.Release()
}
for range 3 {
run, err := store.PrepareFresh(spec)
if err != nil {
b.Fatal(err)
}
if err := store.MarkRunning(run); err != nil {
b.Fatal(err)
}
defer run.Release()
}
b.Run("LiveRuns", func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := store.LiveRuns("p", spec.WorkspaceRoot); err != nil {
b.Fatal(err)
}
}
})
b.Run("Runs", func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := store.Runs("p", spec.WorkspaceRoot); err != nil {
b.Fatal(err)
}
}
})
}
+20
View File
@@ -0,0 +1,20 @@
package delegation
import "reasonix/internal/contract/process"
// ProcessClass projects the run state onto the shared process vocabulary.
func (s RunState) ProcessClass() (process.Class, bool) {
switch s {
case RunRunning:
return process.Class{State: process.Running}, true
case RunCompleted:
return process.Class{State: process.Completed}, true
case RunFailed:
return process.Class{State: process.Failed}, true
case RunCancelled:
return process.Class{State: process.Stopped}, true
case RunInterrupted:
return process.Class{State: process.Interrupted}, true
}
return process.Class{}, false
}
@@ -0,0 +1,12 @@
package delegation
import (
"testing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/process/proctest"
)
func TestRunStateMapsOntoProcessStates(t *testing.T) {
proctest.Total(t, ".", "RunState", func(v string) (process.Class, bool) { return RunState(v).ProcessClass() })
}
+1
View File
@@ -164,6 +164,7 @@ func (r *Runner) prepare(ctx context.Context, spec RunSpec, system string, subRe
WorkspaceRoot: r.WorkspaceRoot,
ParentSession: parentSession,
ParentToolCallID: parentID,
ParentTurn: TurnIdentity(ctx),
SystemPrompt: system,
ExecutionID: executionFor(parentID, spec.Index),
Registry: subReg,
@@ -37,6 +37,7 @@ func metaFromSpec(ref string, status sessionstore.SubagentStatus, created, updat
WorkspaceRoot: capsule.WorkspaceRoot,
ParentSession: capsule.ParentSession,
ParentToolCallID: capsule.ParentToolCallID,
ParentTurn: strings.TrimSpace(spec.ParentTurn),
SystemPromptHash: capsule.SystemPromptHash,
ToolScope: capsule.ToolScope,
ToolSchemaHash: capsule.ToolSchemaHash,
@@ -30,7 +30,9 @@ type SubagentSpec struct {
WorkspaceRoot string
ParentSession string
ParentToolCallID string
SystemPrompt string
// ParentTurn is the in-flight marker id of the turn that spawned the run.
ParentTurn string
SystemPrompt string
// ExecutionID is the execution this run belongs to, allocated before the
// work was opened. A store ref cannot stand in for it: work the scheduler
// is holding back has an execution and no transcript yet.
+36 -1
View File
@@ -50,6 +50,7 @@ type RunInfo struct {
// ParentToolCallID is the call in the parent's transcript that spawned the
// run; it is how a reader pairs a task card with its run.
ParentToolCallID string
ParentTurn string
StartedAt time.Time
UpdatedAt time.Time
}
@@ -114,7 +115,7 @@ func (s *SubagentStore) infoOf(meta sessionstore.SubagentMeta) RunInfo {
}
return RunInfo{
Ref: meta.Ref, Name: meta.Name, Kind: meta.Kind, Model: meta.Model, Effort: meta.Effort,
State: state, ParentSession: meta.ParentSession, ParentToolCallID: meta.ParentToolCallID,
State: state, ParentSession: meta.ParentSession, ParentToolCallID: meta.ParentToolCallID, ParentTurn: meta.ParentTurn,
StartedAt: meta.CreatedAt, UpdatedAt: meta.UpdatedAt,
}
}
@@ -141,6 +142,40 @@ func (s *SubagentStore) Runs(parentSession, workspaceRoot string) ([]RunInfo, er
return out, nil
}
// LiveRuns lists the runs of parentSession that this process is executing now.
// It reads the in-memory registry and one small record per live run, never the
// store's directory, so asking often costs what is running and not what ran.
func (s *SubagentStore) LiveRuns(parentSession, workspaceRoot string) ([]RunInfo, error) {
parentSession = strings.TrimSpace(parentSession)
if s == nil || parentSession == "" {
return nil, nil
}
s.mu.Lock()
refs := make([]string, 0, len(s.live))
for ref := range s.live {
refs = append(refs, ref)
}
s.mu.Unlock()
want := strings.TrimSpace(workspaceRoot)
out := make([]RunInfo, 0, len(refs))
for _, ref := range refs {
meta, err := s.LoadMeta(ref)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, err
}
if strings.TrimSpace(meta.ParentSession) != parentSession || (want != "" && strings.TrimSpace(meta.WorkspaceRoot) != want) {
continue
}
meta.Ref = ref
out = append(out, s.infoOf(meta))
}
slices.SortFunc(out, func(a, b RunInfo) int { return b.StartedAt.Compare(a.StartedAt) })
return out, nil
}
// Transcript reads a run's conversation from index after, at most limit
// messages (a page when zero). A run this process is executing is read from
// memory; any other from its stored transcript.
@@ -0,0 +1,18 @@
package delegation
import "context"
type turnIdentityContextKey struct{}
// WithTurnIdentity carries the running turn's in-flight marker id through the
// tool calls it makes; the transcript has no position for a turn that has not
// ended, which is when a delegation is opened.
func WithTurnIdentity(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, turnIdentityContextKey{}, id)
}
// TurnIdentity returns the turn a context was opened under, or empty.
func TurnIdentity(ctx context.Context) string {
id, _ := ctx.Value(turnIdentityContextKey{}).(string)
return id
}
+24
View File
@@ -0,0 +1,24 @@
package taskmonitor
import "reasonix/internal/contract/process"
// ProcessClass projects the task state onto the shared process vocabulary.
func (s TaskState) ProcessClass() (process.Class, bool) {
switch s {
case TaskStateQueued:
return process.Class{State: process.Queued}, true
case TaskStateRunning:
return process.Class{State: process.Running}, true
case TaskStateWaiting:
return process.Class{State: process.Waiting}, true
case TaskStateSucceeded:
return process.Class{State: process.Completed}, true
case TaskStateFailed:
return process.Class{State: process.Failed}, true
case TaskStateCancelled:
return process.Class{State: process.Stopped}, true
case TaskStateStale:
return process.Class{State: process.Interrupted}, true
}
return process.Class{}, false
}
@@ -0,0 +1,12 @@
package taskmonitor
import (
"testing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/process/proctest"
)
func TestTaskStateMapsOntoProcessStates(t *testing.T) {
proctest.Total(t, ".", "TaskState", func(v string) (process.Class, bool) { return TaskState(v).ProcessClass() })
}
@@ -32,6 +32,7 @@ import (
"reasonix/internal/ext/skill"
"reasonix/internal/runtime/agent"
"reasonix/internal/runtime/coordinator"
"reasonix/internal/runtime/delegation"
"reasonix/internal/runtime/guardian"
"reasonix/internal/runtime/usecap"
"reasonix/internal/safety/permission"
@@ -5244,3 +5245,23 @@ func TestCacheColdAfterFailureFallsBackTo24h(t *testing.T) {
t.Fatalf("ResolveModel failure must fall back to 24h, got %v", got)
}
}
func TestBeginTurnHandsTheMarkerIdToTheToolContext(t *testing.T) {
dir := testenv.TempDir(t)
path := filepath.Join(dir, "session.jsonl")
sess := sessionstore.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
ctx, marker := c.beginTurn(context.Background(), sess.Len(), true)
if marker.ID == "" {
t.Fatal("in-flight marker was not created")
}
if got := delegation.TurnIdentity(ctx); got != marker.ID {
t.Fatalf("tool context turn = %q, want the persisted marker %q", got, marker.ID)
}
meta, ok, err := sessionstore.LoadBranchMeta(path)
if err != nil || !ok || meta.InFlightTurn == nil || meta.InFlightTurn.ID != delegation.TurnIdentity(ctx) {
t.Fatalf("persisted marker = %+v ok=%v err=%v, want the id the context carries", meta.InFlightTurn, ok, err)
}
}
+4
View File
@@ -5,6 +5,7 @@ import (
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/runtime/delegation"
"reasonix/internal/state/execjournal"
)
@@ -15,6 +16,9 @@ func (c *Controller) beginTurn(ctx context.Context, startMessageIndex int, prese
c.applyPeerSwitches()
c.settings.proposals.Store(0)
marker := c.markInFlightTurn(startMessageIndex, preserveUser)
if marker.ID != "" {
ctx = delegation.WithTurnIdentity(ctx, marker.ID)
}
return ctx, marker
}
@@ -0,0 +1,30 @@
package control
import (
"path/filepath"
"strings"
)
// insideWorkspace reports whether every path is a folder strictly inside root,
// after both are resolved through links. The root itself is not inside itself,
// and a path that cannot be resolved is not inside anything: the card decides.
func insideWorkspace(root string, paths []string) bool {
if strings.TrimSpace(root) == "" || len(paths) == 0 {
return false
}
base, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
for _, p := range paths {
resolved, err := filepath.EvalSymlinks(p)
if err != nil {
return false
}
rel, err := filepath.Rel(base, resolved)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return false
}
}
return true
}
+10 -2
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
)
@@ -46,9 +47,12 @@ func (c *Controller) SetProjectOpener(o ProjectOpener) {
// OpenProjects asks the user whether to add the folders to their project list,
// and on a yes has the host add and trust exactly the ones the card named. The
// card is fresh: no mode, grant or hook answers it for the user.
// card is fresh: no mode, grant or hook answers it for the user. The one
// exception is the user's own open_nested setting, which skips it for folders
// inside this session's workspace and says so in a notice.
func (c *Controller) OpenProjects(ctx context.Context, paths []string) ([]tool.OpenedProject, error) {
if !config.LoadForEdit(config.UserConfigPath()).PeersDispatch() {
user := config.LoadForEdit(config.UserConfigPath())
if !user.PeersDispatch() {
return nil, ErrDispatchDisabled
}
if c.PlanMode() {
@@ -62,6 +66,10 @@ func (c *Controller) OpenProjects(ctx context.Context, paths []string) ([]tool.O
if err != nil {
return nil, err
}
if user.PeersOpenNested() && insideWorkspace(c.WorkspaceRoot(), clean) {
c.notice(fmt.Sprintf(i18n.M.OpenedNestedWithoutAskingFmt, len(clean), strings.Join(clean, ", ")))
return (*hook)(ctx, c, clean)
}
args, _ := json.Marshal(map[string]any{"paths": clean})
subject := clean[0]
if len(clean) > 1 {
@@ -3,11 +3,14 @@ package control
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
)
@@ -115,3 +118,120 @@ func TestOpenProjectsNeedsTheDispatchSwitchAndAHost(t *testing.T) {
t.Fatalf("code = %q", got)
}
}
func openNestedController(t *testing.T, root string, on bool) (*Controller, chan event.Approval, *[][]string) {
t.Helper()
setDispatchSwitch(t, true, true)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Peers.OpenNested = on
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
requests := make(chan event.Approval, 8)
c := New(Options{WorkspaceRoot: root, Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
requests <- e.Approval
}
})})
var seen [][]string
c.SetProjectOpener(func(_ context.Context, _ *Controller, paths []string) ([]tool.OpenedProject, error) {
seen = append(seen, paths)
out := make([]tool.OpenedProject, len(paths))
for i, p := range paths {
out[i] = tool.OpenedProject{Path: p, Added: true}
}
return out, nil
})
return c, requests, &seen
}
func TestFoldersInsideTheWorkspaceOpenWithoutACardWhenTheUserAllowedIt(t *testing.T) {
root := testenv.TempDir(t)
inner := filepath.Join(root, "src", "systems")
other := filepath.Join(root, ".agent")
for _, d := range []string{inner, other} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
c, requests, seen := openNestedController(t, root, true)
done := openProjectsAsync(c, inner, other)
select {
case a := <-requests:
t.Fatalf("a card was shown for folders inside the workspace: %+v", a)
case got := <-done:
if got.err != nil || len(*seen) != 1 || len((*seen)[0]) != 2 || len(got.got) != 2 {
t.Fatalf("got %+v err=%v host saw %v, want both folders opened by the host", got.got, got.err, *seen)
}
case <-time.After(10 * time.Second):
t.Fatal("OpenProjects neither opened the folders nor asked")
}
}
func TestALinkInsideTheWorkspaceThatLeavesItStillAsks(t *testing.T) {
root, outside := testenv.TempDir(t), testenv.TempDir(t)
link := filepath.Join(root, "escape")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("cannot make a symlink here: %v", err)
}
c, requests, seen := openNestedController(t, root, true)
done := openProjectsAsync(c, link)
a := nextApproval(t, requests)
if a.Tool != OpenProjectsApprovalTool || len(*seen) != 0 {
t.Fatalf("approval = %+v seen=%v, want a card and no host call", a, *seen)
}
c.Approve(a.ID, false, false, false)
<-done
}
func TestAFolderOutsideTheWorkspaceStillAsksEvenWhenTheOthersAreInside(t *testing.T) {
root, outside := testenv.TempDir(t), testenv.TempDir(t)
inner := filepath.Join(root, "pkg")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
for name, paths := range map[string][]string{
"one outside": {inner, outside},
"parent traversal": {filepath.Join(root, "..", filepath.Base(outside))},
"the root's parent": {filepath.Dir(root)},
} {
t.Run(name, func(t *testing.T) {
c, requests, seen := openNestedController(t, root, true)
done := openProjectsAsync(c, paths...)
a := nextApproval(t, requests)
if a.Tool != OpenProjectsApprovalTool || !a.Fresh {
t.Fatalf("approval = %+v, want the fresh open_projects card", a)
}
if len(*seen) != 0 {
t.Fatal("the host was asked before the click")
}
c.Approve(a.ID, false, false, false)
<-done
})
}
}
func TestInsideTheWorkspaceStillAsksWhenTheUserHasNotAllowedIt(t *testing.T) {
root := testenv.TempDir(t)
inner := filepath.Join(root, "pkg")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
c, requests, seen := openNestedController(t, root, false)
done := openProjectsAsync(c, inner)
a := nextApproval(t, requests)
if a.Tool != OpenProjectsApprovalTool || len(*seen) != 0 {
t.Fatalf("approval = %+v seen=%v, want a card and no host call", a, *seen)
}
c.Approve(a.ID, false, false, false)
<-done
}
func TestAFolderThatDoesNotExistIsNotInsideAnything(t *testing.T) {
root := testenv.TempDir(t)
c, requests, _ := openNestedController(t, root, true)
done := openProjectsAsync(c, filepath.Join(root, "missing"))
a := nextApproval(t, requests)
c.Approve(a.ID, false, false, false)
<-done
}
+14 -7
View File
@@ -44,12 +44,15 @@ type PeersSettings struct {
ReleaseProjects []PeerReleased `json:"release_projects"`
// MaxDispatchDepth and MaxDispatchedSessions are the user's ceilings on chained
// and simultaneous dispatches; 0 removes a ceiling.
MaxDispatchDepth int `json:"max_dispatch_depth"`
MaxDispatchedSessions int `json:"max_dispatched_sessions"`
Path string `json:"path"`
Applies string `json:"applies"`
Pending bool `json:"pending"`
Available bool `json:"available"`
MaxDispatchDepth int `json:"max_dispatch_depth"`
MaxDispatchedSessions int `json:"max_dispatched_sessions"`
// OpenNested is the user's standing yes for open_projects to add folders inside
// the session's own workspace without a card; it takes effect at the next call.
OpenNested bool `json:"open_nested"`
Path string `json:"path"`
Applies string `json:"applies"`
Pending bool `json:"pending"`
Available bool `json:"available"`
}
// PeersPatch is a partial update: a nil field is left as it is, and a non-nil
@@ -61,6 +64,7 @@ type PeersPatch struct {
// Negative values are refused; 0 removes the ceiling.
MaxDispatchDepth *int
MaxDispatchedSessions *int
OpenNested *bool
}
// PeerRemember says what a decision on a held message also writes down.
@@ -81,7 +85,7 @@ func (c *Controller) peersSettingsFrom(cfg config.PeersConfig) PeersSettings {
p := c.peers
out := PeersSettings{
Enabled: cfg.Enabled, Dispatch: cfg.Enabled && cfg.Dispatch, Path: config.UserConfigPath(),
MaxDispatchDepth: max(cfg.MaxDispatchDepth, 0), MaxDispatchedSessions: max(cfg.MaxDispatchedSessions, 0),
MaxDispatchDepth: max(cfg.MaxDispatchDepth, 0), MaxDispatchedSessions: max(cfg.MaxDispatchedSessions, 0), OpenNested: cfg.OpenNested,
Applies: "next_turn", Available: p != nil && p.root != "", ReleaseProjects: []PeerReleased{},
}
for _, path := range cfg.ReleaseProjects {
@@ -123,6 +127,9 @@ func (c *Controller) SavePeersSettings(patch PeersPatch) (PeersSettings, error)
if patch.MaxDispatchedSessions != nil {
p.MaxDispatchedSessions = *patch.MaxDispatchedSessions
}
if patch.OpenNested != nil {
p.OpenNested = *patch.OpenNested
}
return nil
})
if err != nil {
@@ -260,3 +260,25 @@ func TestTheDispatchCeilingsAreSettingsTheUserCanRaiseRemoveAndNotMakeNegative(t
t.Fatal("a refused ceiling changed the file")
}
}
func TestOpeningNestedFoldersWithoutAskingIsOffByDefaultAndTheUsersToTurnOn(t *testing.T) {
f := newStandbyFixture(t)
if f.c.PeersSettings().OpenNested {
t.Fatal("opening folders without a card is on by default")
}
on := true
got, err := f.c.SavePeersSettings(PeersPatch{OpenNested: &on})
if err != nil || !got.OpenNested {
t.Fatalf("saved = %+v, %v; want open_nested on", got, err)
}
if again := f.c.PeersSettings(); !again.OpenNested {
t.Fatal("open_nested did not survive a read-back")
}
if _, err := f.c.SavePeersSettings(PeersPatch{}); err != nil || !f.c.PeersSettings().OpenNested {
t.Fatalf("an empty patch changed open_nested (err %v)", err)
}
off := false
if got, err := f.c.SavePeersSettings(PeersPatch{OpenNested: &off}); err != nil || got.OpenNested {
t.Fatalf("turned off = %+v, %v", got, err)
}
}
+7
View File
@@ -9,6 +9,7 @@ import (
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/pricing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/command"
"reasonix/internal/ext/hook"
@@ -450,6 +451,11 @@ type SubagentView interface {
SubagentTranscript(ref string, after, limit int) (delegation.RunTranscript, error)
}
// ProcessView reads what the session has running now, across owners.
type ProcessView interface {
Processes() ([]process.Process, error)
}
// Mods is Claude Code mods: which are installed and what became of each, and the
// user's switch and budget for running them.
type Mods interface {
@@ -562,6 +568,7 @@ type SurfaceHistory interface {
type SessionAPI interface {
AgentCompositions
ProcessView
AgentPresetManagement
SurfaceActions
SurfaceHistory
+58
View File
@@ -0,0 +1,58 @@
package control
import (
"time"
"reasonix/internal/contract/process"
)
// Processes lists the work this session has running now, across owners. It
// reads in-memory state only and costs what is running, not what ran.
func (c *Controller) Processes() ([]process.Process, error) {
var out []process.Process
for _, j := range c.Jobs() {
out = append(out, process.Process{
ID: process.ID(process.KindJob, j.ID),
Kind: process.KindJob,
Name: firstNonBlank(j.Description, j.Label),
State: process.Running,
Started: time.UnixMilli(j.StartedAt),
})
}
parent := c.parentSessionID()
if c.subagents == nil || parent == "" {
return out, nil
}
runs, err := c.subagents.LiveRuns(parent, c.WorkspaceRoot())
if err != nil {
return out, err
}
for _, r := range runs {
class, ok := r.State.ProcessClass()
if !ok || class.State.Terminal() {
continue
}
p := process.Process{
ID: process.ID(process.KindSubagent, r.Ref),
Kind: process.KindSubagent,
Name: r.Name,
State: class.State,
Cause: class.Cause,
Started: r.StartedAt,
}
if r.ParentTurn != "" {
p.Parent = "turn:" + r.ParentTurn
}
out = append(out, p)
}
return out, nil
}
func firstNonBlank(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
@@ -0,0 +1,62 @@
package control
import (
"context"
"io"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/process"
"reasonix/internal/runtime/delegation"
"reasonix/internal/tools/jobs"
)
type liveSubagents struct {
SubagentReader
runs []delegation.RunInfo
}
func (l liveSubagents) LiveRuns(parent, workspace string) ([]delegation.RunInfo, error) {
return l.runs, nil
}
func TestProcessesListsWhatIsRunningAcrossOwners(t *testing.T) {
m := jobs.NewManager(event.Discard)
t.Cleanup(m.Close)
job := m.Start("bash", "sleep 60", func(ctx context.Context, _ io.Writer) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
dir := testenv.TempDir(t)
c := New(Options{Sink: event.Discard, SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Jobs: m, Subagents: liveSubagents{runs: []delegation.RunInfo{
{Ref: "sa_1", Name: "audit", State: delegation.RunRunning, ParentTurn: "turn-3"},
{Ref: "sa_2", Name: "late", State: delegation.RunCompleted},
}}})
got, err := c.Processes()
if err != nil {
t.Fatalf("Processes: %v", err)
}
byID := map[string]process.Process{}
for _, p := range got {
byID[p.ID] = p
}
if len(got) != 2 {
t.Fatalf("got %d processes %+v, want the running job and the running sub-agent only", len(got), got)
}
if p := byID[process.ID(process.KindJob, job.ID)]; p.Kind != process.KindJob || p.State != process.Running || p.Name != "sleep 60" {
t.Fatalf("job process = %+v", p)
}
if p := byID["subagent:sa_1"]; p.State != process.Running || p.Parent != "turn:turn-3" || p.Name != "audit" {
t.Fatalf("sub-agent process = %+v", p)
}
}
func TestProcessesOfABareControllerIsEmpty(t *testing.T) {
got, err := New(Options{Sink: event.Discard}).Processes()
if err != nil || len(got) != 0 {
t.Fatalf("bare controller: %+v err=%v", got, err)
}
}
@@ -8,6 +8,7 @@ import (
// conversation spawned, and one run's conversation, live or stored.
type SubagentReader interface {
Runs(parentSession, workspaceRoot string) ([]delegation.RunInfo, error)
LiveRuns(parentSession, workspaceRoot string) ([]delegation.RunInfo, error)
Transcript(ref, parentSession, workspaceRoot string, after, limit int) (delegation.RunTranscript, error)
}
+26
View File
@@ -0,0 +1,26 @@
package schedule
import "reasonix/internal/contract/process"
// ProcessClass projects the run state onto the shared process vocabulary.
func (s RunState) ProcessClass() (process.Class, bool) {
switch s {
case RunClaimed:
return process.Class{State: process.Queued}, true
case RunRunning:
return process.Class{State: process.Running}, true
case RunSucceeded:
return process.Class{State: process.Completed}, true
case RunFailed:
return process.Class{State: process.Failed}, true
case RunBlocked:
return process.Class{State: process.Stopped, Cause: process.CauseParked}, true
case RunBudgetStopped:
return process.Class{State: process.Stopped, Cause: process.CauseBudget}, true
case RunInterrupted:
return process.Class{State: process.Interrupted}, true
case RunSkipped:
return process.Class{State: process.Skipped}, true
}
return process.Class{}, false
}
+12
View File
@@ -0,0 +1,12 @@
package schedule
import (
"testing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/process/proctest"
)
func TestRunStateMapsOntoProcessStates(t *testing.T) {
proctest.Total(t, ".", "RunState", func(v string) (process.Class, bool) { return RunState(v).ProcessClass() })
}
+20
View File
@@ -0,0 +1,20 @@
package sessioninbox
import "reasonix/internal/contract/process"
// ProcessClass projects the item state onto the shared process vocabulary.
func (s InboxState) ProcessClass() (process.Class, bool) {
switch s {
case StateQueued:
return process.Class{State: process.Queued}, true
case StateBlocked:
return process.Class{State: process.Queued, Cause: process.CauseHeld}, true
case StateRunning, StateSteerAccepted:
return process.Class{State: process.Running}, true
case StateSteerConsumed:
return process.Class{State: process.Completed}, true
case StateUncertain:
return process.Class{State: process.Interrupted}, true
}
return process.Class{}, false
}
@@ -0,0 +1,12 @@
package sessioninbox
import (
"testing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/process/proctest"
)
func TestInboxStateMapsOntoProcessStates(t *testing.T) {
proctest.Total(t, ".", "InboxState", func(v string) (process.Class, bool) { return InboxState(v).ProcessClass() })
}
@@ -148,7 +148,7 @@ func recoveryArchiveLineage(sessionPath string, except []string) ([]string, erro
root := recoveryArchiveRoot(target, byID)
paths := []string{abs}
for _, info := range listed {
if info.Path == abs || excluded[CanonicalSessionPath(info.Path)] || !info.Recovered || recoveryArchiveRoot(info, byID) != root {
if info.Path == abs || !info.Recovered || recoveryArchiveRoot(info, byID) != root || (len(excluded) > 0 && excluded[CanonicalSessionPath(info.Path)]) {
continue
}
paths = append(paths, info.Path)
@@ -0,0 +1,32 @@
package sessionstore
import (
"fmt"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/provider"
)
func BenchmarkSetSessionLineageArchived(b *testing.B) {
dir := testenv.TempDir(b)
const n = 200
paths := make([]string, n)
for i := range paths {
p := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
if err := s.SaveSnapshot(p); err != nil {
b.Fatal(err)
}
paths[i] = p
}
b.ReportAllocs()
b.ResetTimer()
for i := range b.N {
if err := SetSessionLineageArchived(paths[i%n], i%2 == 0); err != nil {
b.Fatal(err)
}
}
}
@@ -54,6 +54,7 @@ type SubagentMeta struct {
WorkspaceRoot string `json:"workspaceRoot"`
ParentSession string `json:"parentSession,omitempty"`
ParentToolCallID string `json:"parentToolCallId,omitempty"`
ParentTurn string `json:"parentTurn,omitempty"`
ForkedFrom string `json:"forkedFrom,omitempty"`
SystemPromptHash string `json:"systemPromptHash"`
ToolScope []string `json:"toolScope"`
+20
View File
@@ -0,0 +1,20 @@
package jobs
import "reasonix/internal/contract/process"
// ProcessClass projects the job state onto the shared process vocabulary.
func (s Status) ProcessClass() (process.Class, bool) {
switch s {
case Running:
return process.Class{State: process.Running}, true
case Done:
return process.Class{State: process.Completed}, true
case Failed:
return process.Class{State: process.Failed}, true
case Killed:
return process.Class{State: process.Stopped}, true
case Interrupted:
return process.Class{State: process.Interrupted}, true
}
return process.Class{}, false
}
+12
View File
@@ -0,0 +1,12 @@
package jobs
import (
"testing"
"reasonix/internal/contract/process"
"reasonix/internal/contract/process/proctest"
)
func TestStatusMapsOntoProcessStates(t *testing.T) {
proctest.Total(t, ".", "Status", func(v string) (process.Class, bool) { return Status(v).ProcessClass() })
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more