Skip to content

Commit 0b6e1b8

Browse files
committed
Daemon: requirement start gate, project directories and agent proposals
- A session started on a requirement first explains its understanding read-only and waits for the user's confirmation (requirement_begin / requirement_confirm); with a plan, it plans and waits once more. Only a turn that ended at the stage shows it as `confirm`. - Requirements belong to a project id (or none); old paths migrate once. - Projects take extra directories (`dirs`): Claude --add-dir, Codex writable roots, the jucode sandbox's writable dirs. - Agents belong to a project; their `requirements` tool lists requirements, updates progress and only proposes noting or closing one (requirement_proposal). - requirement_prompt is removed.
1 parent 4f8773e commit 0b6e1b8

13 files changed

Lines changed: 1147 additions & 163 deletions

File tree

‎crates/agent-core/src/core.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,17 @@ impl AgentCore {
330330
self.tool_state.set_sandbox(sandbox);
331331
}
332332

333+
/// Counts `dirs` as workspace: file tools may write them, and sandboxed
334+
/// commands too.
335+
pub fn add_writable_dirs(&mut self, dirs: &[PathBuf]) {
336+
let mut sandbox = self
337+
.tool_state
338+
.sandbox()
339+
.unwrap_or_else(|| self.config.sandbox.clone());
340+
sandbox.writable_dirs.extend_from_slice(dirs);
341+
self.tool_state.set_sandbox(Some(sandbox));
342+
}
343+
333344
/// Adds host tools and prompt text; they apply from the next turn.
334345
pub fn set_host_extensions(&mut self, host: crate::host::HostExtensions) {
335346
self.host = Some(host);

‎crates/daemon/src/agent_tools.rs‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Tools a long-lived agent's sessions get from the daemon, added to the
22
//! engine through `HostExtensions`: `message_agent`, `timer`, `schedule`,
3-
//! `brief`, `question` and `report`.
3+
//! `brief`, `question`, `report` and `requirements`.
44
55
use crate::{
66
hub::Hub,
@@ -29,11 +29,44 @@ pub fn extensions(hub: Arc<Hub>, agent: String, session: String) -> HostExtensio
2929
Err(error) => (json!({ "error": error }).to_string(), true),
3030
}
3131
}),
32-
prompt: Arc::new(move || prompt_hub.agents.prompt(&prompt_agent, &prompt_session)),
32+
prompt: Arc::new(move || {
33+
let mut prompt = prompt_hub.agents.prompt(&prompt_agent, &prompt_session);
34+
prompt.push_str(&project_prompt(&prompt_hub, &prompt_agent));
35+
prompt
36+
}),
3337
exclusive: false,
3438
}
3539
}
3640

41+
/// The project the agent belongs to: its name and directories.
42+
fn project_prompt(hub: &Hub, agent: &str) -> String {
43+
let Some(project) = hub
44+
.agents
45+
.get(agent)
46+
.and_then(|agent| agent.project)
47+
.and_then(|id| crate::projects::project(hub, &id))
48+
else {
49+
return String::new();
50+
};
51+
let dirs: Vec<&str> = project["dirs"]
52+
.as_array()
53+
.into_iter()
54+
.flatten()
55+
.filter_map(Value::as_str)
56+
.collect();
57+
format!(
58+
"\n<project id=\"{}\" name=\"{}\">\nYou belong to this project; the `requirements` tool lists its requirements.\nMain directory: {}\nExtra directories: {}\n</project>",
59+
project["id"].as_str().unwrap_or_default(),
60+
project["name"].as_str().unwrap_or_default(),
61+
project["path"].as_str().unwrap_or_default(),
62+
if dirs.is_empty() {
63+
"none".to_string()
64+
} else {
65+
dirs.join(", ")
66+
}
67+
)
68+
}
69+
3770
/// The dispatcher's tools (see `dispatch`), plus `question`.
3871
fn dispatcher(hub: Arc<Hub>, session: String) -> HostExtensions {
3972
let prompt_hub = Arc::clone(&hub);
@@ -229,6 +262,10 @@ fn run(
229262
hub.post_report(&report)?;
230263
Ok(json!({ "report": report.id }))
231264
}
265+
"requirements" => {
266+
let project = hub.agents.get(agent).and_then(|agent| agent.project);
267+
crate::requirements::tool(hub, agent, project.as_deref(), session, args)
268+
}
232269
other => Err(format!("unknown tool {other}")),
233270
}
234271
}
@@ -335,5 +372,30 @@ fn definitions() -> Vec<Value> {
335372
"additionalProperties": false
336373
}
337374
}),
375+
json!({
376+
"type": "function",
377+
"name": "requirements",
378+
"description": "The user's requirements (what they mean to get done). `list` shows those of your project (`project`: another project's id, or `none` for unassigned ones; `state` filters). `get` shows one in full: words, progress, start gate, pending proposal. `propose_create` and `propose_close` only propose: the user is asked, and nothing changes until they accept. Do not propose the same thing twice; a pending proposal shows in `list`. `progress` adds to a requirement's progress record directly: list items are appended once each.",
379+
"parameters": {
380+
"type": "object",
381+
"properties": {
382+
"action": { "type": "string", "enum": ["list", "get", "propose_create", "propose_close", "progress"] },
383+
"requirement": { "type": "string", "description": "Requirement id (R-12), for get, propose_close and progress." },
384+
"project": { "type": "string", "description": "For list: a project id, or none for unassigned requirements." },
385+
"state": { "type": "string", "enum": ["idea", "open", "done", "parked", "proposed"], "description": "For list." },
386+
"text": { "type": "string", "description": "For propose_create: the requirement in the user's language." },
387+
"reason": { "type": "string", "description": "For propose_create and propose_close: why, in one line the user reads." },
388+
"outcome": { "type": "string", "enum": ["done", "parked"], "description": "For propose_close." },
389+
"decided": { "type": "array", "items": { "type": "string" } },
390+
"done": { "type": "array", "items": { "type": "string" } },
391+
"doing": { "type": "array", "items": { "type": "string" } },
392+
"blocked": { "type": "array", "items": { "type": "string" } },
393+
"next": { "type": "array", "items": { "type": "string" } },
394+
"note": { "type": "string", "description": "For progress: a note that replaces the previous one." }
395+
},
396+
"required": ["action"],
397+
"additionalProperties": false
398+
}
399+
}),
338400
]
339401
}

‎crates/daemon/src/agents.rs‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ pub struct Agent {
6060
/// workspaces had their own (clients put those in the default one). It
6161
/// runs whichever workspace is open.
6262
pub workspace: Option<String>,
63+
/// The project it belongs to (its `cwd` is the project's main directory).
64+
pub project: Option<String>,
6365
}
6466

6567
#[derive(Debug, Clone, PartialEq)]
@@ -84,6 +86,7 @@ impl Agent {
8486
"mode": dir.mode,
8587
})).collect::<Vec<_>>(),
8688
"command_rules": rules_to_json(&self.command_rules),
89+
"project": self.project,
8790
});
8891
if let Some(icon) = &self.icon {
8992
value["icon"] = icon.clone();
@@ -182,6 +185,7 @@ impl Agents {
182185
color: value["color"].as_str().map(str::to_string),
183186
avatar_seed: value["avatar_seed"].as_str().map(str::to_string),
184187
workspace: value["workspace"].as_str().map(str::to_string),
188+
project: value["project"].as_str().map(str::to_string),
185189
})
186190
}
187191

@@ -228,12 +232,14 @@ impl Agents {
228232
color: None,
229233
avatar_seed: None,
230234
workspace: None,
235+
project: None,
231236
};
232237
let mut settings = agent.to_json();
233238
settings.as_object_mut().map(|map| map.remove("id"));
234239
settings["avatar_seed"] = json!(random_hex(8).map_err(|error| error.to_string())?);
235240
set_appearance(&mut settings, appearance)?;
236241
set_workspace(&mut settings, appearance);
242+
set_project(&mut settings, appearance)?;
237243
let write = || -> io::Result<()> {
238244
fs::create_dir_all(dir.join("memory"))?;
239245
fs::write(
@@ -252,8 +258,9 @@ impl Agents {
252258

253259
/// Changes the settings present in `changes` (`name`, `enabled`,
254260
/// `approval_mode`, `sandbox`, `network`, `directories`,
255-
/// `command_rules`, `icon`, `color`, `avatar_seed`, `workspace`), keeping the rest of
256-
/// `agent.json`; `role` rewrites `role.md`.
261+
/// `command_rules`, `icon`, `color`, `avatar_seed`, `workspace`,
262+
/// `project` with `cwd`), keeping the rest of `agent.json`; `role`
263+
/// rewrites `role.md`.
257264
pub fn update(&self, id: &str, changes: &Value) -> Result<Agent, String> {
258265
if !valid_id(id) {
259266
return Err(format!("unknown agent {id}"));
@@ -295,6 +302,7 @@ impl Agents {
295302
}
296303
set_appearance(&mut settings, changes)?;
297304
set_workspace(&mut settings, changes);
305+
set_project(&mut settings, changes)?;
298306
let text = serde_json::to_string_pretty(&settings).map_err(|error| error.to_string())?;
299307
fs::write(&path, text + "\n").map_err(|error| error.to_string())?;
300308
if let Some(role) = changes["role"].as_str() {
@@ -545,6 +553,26 @@ fn set_workspace(settings: &mut Value, changes: &Value) {
545553
}
546554
}
547555

556+
/// Applies `project` from `changes` (null clears it). A project comes with
557+
/// `cwd`, its main directory, where the agent then works.
558+
fn set_project(settings: &mut Value, changes: &Value) -> Result<(), String> {
559+
match changes.get("project") {
560+
Some(Value::String(id)) => {
561+
let cwd = changes["cwd"]
562+
.as_str()
563+
.ok_or("a project needs its directory")?;
564+
if !Path::new(cwd).is_dir() {
565+
return Err(format!("not a directory: {cwd}"));
566+
}
567+
settings["project"] = json!(id);
568+
settings["cwd"] = json!(cwd);
569+
}
570+
Some(Value::Null) => settings["project"] = Value::Null,
571+
_ => {}
572+
}
573+
Ok(())
574+
}
575+
548576
/// Applies `icon`, `color` and `avatar_seed` from `changes`; `null` clears
549577
/// one. Only shapes and sizes are checked here: an SVG icon is sanitized by
550578
/// the clients that draw it.

‎crates/daemon/src/engines/claude.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,17 @@ pub fn command(id: &str, options: &Options) -> Command {
156156
{
157157
command.args(["--effort", effort]);
158158
}
159+
add_dirs(&mut command, options);
159160
command
160161
}
161162

163+
/// The project's extra directories, which Claude Code may work in too.
164+
fn add_dirs(command: &mut Command, options: &Options) {
165+
for dir in &options.dirs {
166+
command.arg("--add-dir").arg(dir);
167+
}
168+
}
169+
162170
/// Claude Code's own TUI resuming conversation `id`, in the session's
163171
/// permission mode and model (for the GUI ⇄ TUI handoff).
164172
pub fn tui(id: &str, options: &Options, saved: bool) -> Command {
@@ -184,6 +192,7 @@ pub fn tui(id: &str, options: &Options, saved: bool) -> Command {
184192
if let Some(model) = &options.model {
185193
command.args(["--model", model]);
186194
}
195+
add_dirs(&mut command, options);
187196
// A daemon started from inside Claude Code inherits that session's
188197
// markers; with them the TUI counts as a nested child and does not save
189198
// its transcript, so turns typed there would be lost on the way back.
@@ -3430,4 +3439,21 @@ mod tests {
34303439
#[cfg(unix)]
34313440
assert_eq!(project_dir(&home, &link), project_dir(&home, &real));
34323441
}
3442+
3443+
#[test]
3444+
fn the_projects_extra_dirs_are_added_to_both_commands() {
3445+
let options = Options {
3446+
dirs: vec![PathBuf::from("/work/api"), PathBuf::from("/work/docs")],
3447+
..Options::default()
3448+
};
3449+
for command in [command("s-1", &options), tui("s-1", &options, true)] {
3450+
let args: Vec<String> = command
3451+
.get_args()
3452+
.map(|a| a.to_string_lossy().to_string())
3453+
.collect();
3454+
let joined = args.join(" ");
3455+
assert!(joined.contains("--add-dir /work/api"), "{joined}");
3456+
assert!(joined.contains("--add-dir /work/docs"), "{joined}");
3457+
}
3458+
}
34333459
}

‎crates/daemon/src/engines/codex.rs‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ pub fn tui(id: Option<&str>, options: &Options) -> Command {
5757
if mode == "full-auto" {
5858
command.arg("--dangerously-bypass-approvals-and-sandbox");
5959
} else {
60-
let (approval, sandbox) = policy(mode);
60+
let (approval, sandbox) = policy(mode, &options.dirs);
6161
command.args(["-a", approval, "-s", sandbox_mode(&sandbox)]);
6262
}
6363
if let Some(model) = &options.model {
6464
command.args(["-m", model]);
6565
}
66+
for dir in &options.dirs {
67+
command.arg("--add-dir").arg(dir);
68+
}
6669
command
6770
}
6871

@@ -145,13 +148,14 @@ fn engine_mode(mode: &str) -> &'static str {
145148
}
146149
}
147150

148-
/// The approval policy and sandbox policy of an engine mode.
149-
fn policy(mode: &str) -> (&'static str, Value) {
151+
/// The approval policy and sandbox policy of an engine mode; `dirs` (the
152+
/// project's extra directories) are writable besides the workspace.
153+
fn policy(mode: &str, dirs: &[PathBuf]) -> (&'static str, Value) {
150154
match mode {
151155
// Auto: workspace writes, with a reviewer subagent deciding approvals.
152156
"auto-edit" | "auto" => (
153157
"on-request",
154-
json!({ "type": "workspaceWrite", "writableRoots": [], "networkAccess": false, "excludeTmpdirEnvVar": false, "excludeSlashTmp": false }),
158+
json!({ "type": "workspaceWrite", "writableRoots": dirs, "networkAccess": false, "excludeTmpdirEnvVar": false, "excludeSlashTmp": false }),
155159
),
156160
"full-auto" => ("never", json!({ "type": "dangerFullAccess" })),
157161
_ => (
@@ -373,6 +377,8 @@ impl Item {
373377

374378
pub struct Codex {
375379
cwd: PathBuf,
380+
/// The project's extra directories: writable in workspace-write.
381+
dirs: Vec<PathBuf>,
376382
mode: &'static str,
377383
next_id: u64,
378384
/// Our outstanding requests: id → (method, tag).
@@ -440,6 +446,7 @@ impl Codex {
440446
pub fn new(cwd: &Path, options: &Options) -> Self {
441447
Self {
442448
cwd: cwd.to_path_buf(),
449+
dirs: options.dirs.clone(),
443450
mode: engine_mode(options.approval_mode.as_deref().unwrap_or_default()),
444451
next_id: 0,
445452
pending: HashMap::new(),
@@ -488,7 +495,7 @@ impl Codex {
488495
}
489496

490497
fn turn_start(&mut self, input: Vec<Value>) -> String {
491-
let (approval, sandbox) = policy(self.mode);
498+
let (approval, sandbox) = policy(self.mode, &self.dirs);
492499
let mut params = json!({
493500
"threadId": self.thread,
494501
"input": input,
@@ -844,7 +851,7 @@ impl Codex {
844851
}
845852
match method.as_str() {
846853
"initialize" => {
847-
let (approval, sandbox) = policy(self.mode);
854+
let (approval, sandbox) = policy(self.mode, &self.dirs);
848855
let open = json!({ "cwd": self.cwd, "approvalPolicy": approval, "sandbox": sandbox_mode(&sandbox) });
849856
self.open_params = open.clone();
850857
let mut frames =
@@ -2894,4 +2901,24 @@ mod tests {
28942901
("a", "fix login")
28952902
);
28962903
}
2904+
2905+
#[test]
2906+
fn the_projects_extra_dirs_are_writable_roots() {
2907+
let options = Options {
2908+
approval_mode: Some("auto-edit".to_string()),
2909+
dirs: vec![PathBuf::from("/work/api")],
2910+
..Options::default()
2911+
};
2912+
let mut c = Codex::new(Path::new("/work/app"), &options);
2913+
let turn: Value = serde_json::from_str(&c.turn_start(Vec::new())).unwrap();
2914+
assert_eq!(
2915+
turn["params"]["sandboxPolicy"]["writableRoots"],
2916+
json!(["/work/api"])
2917+
);
2918+
let args: Vec<String> = tui(Some("t-1"), &options)
2919+
.get_args()
2920+
.map(|a| a.to_string_lossy().to_string())
2921+
.collect();
2922+
assert!(args.join(" ").contains("--add-dir /work/api"), "{args:?}");
2923+
}
28972924
}

‎crates/daemon/src/engines/mod.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ pub struct Options {
8585
pub fast: bool,
8686
/// Claude: show thinking summaries (Some(false) hides them).
8787
pub thinking: Option<bool>,
88+
/// Claude / Codex: directories besides `cwd` the engine may work in (the
89+
/// project's extra directories; set by `spawn`, never by a client).
90+
pub dirs: Vec<PathBuf>,
8891
}
8992

9093
impl Options {
@@ -120,6 +123,7 @@ impl Options {
120123
effort: text("effort"),
121124
fast: value["fast"] == true,
122125
thinking: value["thinking"].as_bool(),
126+
dirs: Vec::new(),
123127
}
124128
}
125129

@@ -527,6 +531,10 @@ pub fn spawn(
527531
options: Options,
528532
transcript: Vec<Value>,
529533
) -> Result<(String, Sender<Value>, u64), String> {
534+
let options = Options {
535+
dirs: crate::projects::extra_dirs(&hub, &cwd),
536+
..options
537+
};
530538
let (command, gateway_key) = command(kind, id.as_deref().unwrap_or_default(), &options)?;
531539
let process = match Process::spawn(command, &cwd) {
532540
Ok(process) => process,
@@ -724,6 +732,7 @@ impl Session<'_> {
724732
next.env = options.env.clone();
725733
next.gateway = next.gateway.or(options.gateway);
726734
next.approval_mode = next.approval_mode.or(options.approval_mode.clone());
735+
next.dirs = crate::projects::extra_dirs(self.hub, &self.cwd);
727736
current = next.clone();
728737
process.stop();
729738
let id = self.id.clone().unwrap_or_default();

‎crates/daemon/src/hub.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ impl Hub {
118118
let requirements = crate::requirements::Requirements::load(
119119
store.dir(),
120120
uploads.dir().join("requirements"),
121+
&store.workspaces(),
121122
);
122123
Arc::new_cyclic(|me| Self {
123124
me: me.clone(),

0 commit comments

Comments
 (0)