convos-orchestrate 0.11.6b37__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. convos_orchestrate-0.11.6b37/PKG-INFO +25 -0
  2. convos_orchestrate-0.11.6b37/README.md +13 -0
  3. convos_orchestrate-0.11.6b37/pyproject.toml +28 -0
  4. convos_orchestrate-0.11.6b37/setup.cfg +4 -0
  5. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/SKILL.md +87 -0
  6. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/__init__.py +236 -0
  7. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/channels.py +52 -0
  8. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/mcp.py +52 -0
  9. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/runtime.py +118 -0
  10. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/server.py +299 -0
  11. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/setup.py +20 -0
  12. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/store.py +274 -0
  13. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/web/app.js +1063 -0
  14. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/web/index.html +14 -0
  15. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/web/style.css +429 -0
  16. convos_orchestrate-0.11.6b37/src/ai_convos_orchestrate/web/vendor/preact-htm.js +2 -0
  17. convos_orchestrate-0.11.6b37/src/convos_orchestrate.egg-info/PKG-INFO +25 -0
  18. convos_orchestrate-0.11.6b37/src/convos_orchestrate.egg-info/SOURCES.txt +20 -0
  19. convos_orchestrate-0.11.6b37/src/convos_orchestrate.egg-info/dependency_links.txt +1 -0
  20. convos_orchestrate-0.11.6b37/src/convos_orchestrate.egg-info/entry_points.txt +2 -0
  21. convos_orchestrate-0.11.6b37/src/convos_orchestrate.egg-info/requires.txt +2 -0
  22. convos_orchestrate-0.11.6b37/src/convos_orchestrate.egg-info/top_level.txt +1 -0
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: convos-orchestrate
3
+ Version: 0.11.6b37
4
+ Summary: Agentic IDE for Convos: a durable home for agent sessions, their work items, results, and conversations
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://github.com/RobertBiehl/convos/blob/master/docs/orchestrate.md
7
+ Project-URL: Repository, https://github.com/RobertBiehl/convos
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: convos<0.12,>=0.11.6b37
11
+ Requires-Dist: typer>=0.12.0
12
+
13
+ # convos-orchestrate
14
+
15
+ A keyboard-first agentic IDE for [Convos](https://github.com/RobertBiehl/convos). Every agent
16
+ session reports items with statuses, results, threads, and questions; you steer them from one
17
+ board in your browser.
18
+
19
+ ```bash
20
+ uv tool install convos --with convos-orchestrate
21
+ convos orchestrate install # /orchestrate for Claude Code, $orchestrate for Codex
22
+ convos orchestrate open # the IDE in your browser
23
+ ```
24
+
25
+ This is a beta. See the [full documentation](https://github.com/RobertBiehl/convos/blob/master/docs/orchestrate.md).
@@ -0,0 +1,13 @@
1
+ # convos-orchestrate
2
+
3
+ A keyboard-first agentic IDE for [Convos](https://github.com/RobertBiehl/convos). Every agent
4
+ session reports items with statuses, results, threads, and questions; you steer them from one
5
+ board in your browser.
6
+
7
+ ```bash
8
+ uv tool install convos --with convos-orchestrate
9
+ convos orchestrate install # /orchestrate for Claude Code, $orchestrate for Codex
10
+ convos orchestrate open # the IDE in your browser
11
+ ```
12
+
13
+ This is a beta. See the [full documentation](https://github.com/RobertBiehl/convos/blob/master/docs/orchestrate.md).
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "convos-orchestrate"
3
+ version = "0.11.6b37"
4
+ description = "Agentic IDE for Convos: a durable home for agent sessions, their work items, results, and conversations"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ dependencies = ["convos>=0.11.6b37,<0.12", "typer>=0.12.0"]
9
+
10
+ [project.urls]
11
+ Documentation = "https://github.com/RobertBiehl/convos/blob/master/docs/orchestrate.md"
12
+ Repository = "https://github.com/RobertBiehl/convos"
13
+
14
+ [project.entry-points."convos.commands"]
15
+ orchestrate = "ai_convos_orchestrate:register"
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=68"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.setuptools.package-data]
25
+ ai_convos_orchestrate = ["SKILL.md", "web/*", "web/vendor/*"]
26
+
27
+ [tool.uv.sources]
28
+ convos = { workspace = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: orchestrate
3
+ description: Report this session's work to Convos Orchestrate, the user's agentic IDE. Use when the user types /orchestrate or $orchestrate, or when a session was started from orchestrate. Gives tools to publish work items with statuses, live activity, results (images, PDFs, markdown, links), chat threads, and questions the user answers on the board.
4
+ ---
5
+
6
+ # Orchestrate
7
+
8
+ The user watches and steers your work in the orchestrate IDE instead of your terminal.
9
+ You get tools; how you use them is your call. Keep the board truthful: it is what the
10
+ user sees of you.
11
+
12
+ ## Join
13
+
14
+ ```sh
15
+ convos orchestrate join --title "SHORT GOAL OF THIS SESSION"
16
+ ```
17
+
18
+ Joining is idempotent and binds to your provider session automatically (Claude Code and
19
+ Codex expose their session id to your shell). If join says no provider session was
20
+ detected, pass `-s SESSION` on every later command.
21
+
22
+ ## Tools
23
+
24
+ | Command | Purpose |
25
+ |---|---|
26
+ | `add TITLE [-d MARKDOWN] [--status S] [-p PARENT] [--again]` | Add an item (a unit of work). Prints its id, e.g. `i3`. A title already under the same parent returns that row and adds nothing; `--again` adds another. |
27
+ | `set ITEM [--status S] [--title T] [-d MARKDOWN]` | Update an item. `-d -` reads stdin. Only real changes are written. |
28
+ | `now "TEXT"` | One-line live activity for the session ("Running the test suite"). `now ""` clears. |
29
+ | `attach FILE_OR_URL --on ITEM [--caption C]` | Append a result: image, video, PDF, markdown, any file, or a link. |
30
+ | `attach --text MARKDOWN --on ITEM --name NAME.md` | Append an inline markdown result. |
31
+ | `say "TEXT" [--on ITEM] [--reply COMMENT]` | Write in the session chat, or in an item's thread. |
32
+ | `ask "QUESTION" [-o A -o B] [--multi] [--on ITEM] [--detail MD] [--wait SEC]` | Ask the user. The item shows *needs you* until answered. |
33
+ | `inbox [--wait SEC]` | Receive user comments and answers not yet delivered to you. |
34
+ | `show` | Your board as the user sees it. Rows are marked `· unseen by the user`, `· archivable`, or `(archived)`. |
35
+ | `archive ITEM...` | Move finished top-level rows, with everything under them, off the Current board. |
36
+ | `close` | Close the session; the board stays available. |
37
+
38
+ `add` and `set` are safe to repeat, so a script that builds your board can run again without
39
+ duplicating it. Statuses: `planned`, `working`, `blocked`, `done`, `failed`, `skipped`. An open question
40
+ on an item makes it show as *needs you*. The session status is derived from its items.
41
+
42
+ Keep Current about the work at hand: once the work has moved on, archive the rows `show` marks
43
+ `· archivable`. Those rows are finished, with no open questions, and the user has looked at
44
+ everything in them since it last changed. Anything else is refused with the reason, so never
45
+ archive to tidy up work the user has not seen. Anything new on an archived row brings it back,
46
+ and only the user restores rows.
47
+
48
+ All commands accept `--json` for structured output.
49
+
50
+ ## The user talks back
51
+
52
+ The user comments on the session or on a specific item, and answers your questions.
53
+ These arrive as messages starting with `[orchestrate]` (headless sessions), as hook
54
+ context (interactive sessions), or via `inbox`. Treat them as the user's words about the
55
+ named target. Respond however fits: answer in that item's thread with
56
+ `say --on ITEM --reply COMMENT`, update the item, add new items, change course, or ask
57
+ back. Reply where the user spoke so the conversation stays attached to its item.
58
+
59
+ A comment can carry images (screenshots, mockups). Each arrives as an `image: PATH` line
60
+ under the comment; open the file to see it before you act on the comment.
61
+
62
+ ## Subagents
63
+
64
+ A subagent works on one row. Before delegating, create or pick the item, then give the
65
+ subagent this exact prefix for its commands:
66
+
67
+ ```sh
68
+ convos orchestrate --scope ITEM --as NAME ...
69
+ ```
70
+
71
+ With `--scope`, writes are confined to that item's subtree: new items default under it,
72
+ and comments, results, and questions land on it. Subagents share your session, so they
73
+ need no join. `inbox` with `--scope` returns only messages for that subtree.
74
+
75
+ ## Results
76
+
77
+ Prefer attaching the real artifact (a screenshot, a screen recording, the generated PDF,
78
+ the report file) over describing it. Videos play in the IDE as MP4 (H.264) or WebM. Links to PRs, commits, and dashboards are results too.
79
+
80
+ ## References
81
+
82
+ Point at anything by its id in any text you write (`say`, `add -d`, `set -d`, `ask`,
83
+ `--detail`, captions, markdown results): rows `i3`, results `a2`, questions `q1`,
84
+ comments `c5`, other sessions `o5619965e7ab`, and rows in other sessions
85
+ `o5619965e7ab:i3`. The IDE turns them into links. `show` lists every id, including each
86
+ row's results. The user references the same way, so read ids in their messages as
87
+ pointers.
@@ -0,0 +1,236 @@
1
+ """Convos Orchestrate: an agentic IDE. Agents report items, results, and questions; people steer them.
2
+
3
+ Core owns history. This product owns the agent tools (this CLI), delivery hooks, the headless
4
+ runtime, and the local web IDE.
5
+ """
6
+ import functools, inspect, json, mimetypes, os, sys
7
+ from pathlib import Path
8
+ import typer
9
+
10
+ app = typer.Typer(help="Agentic IDE: a durable home for agent sessions, their items, results, and conversations.", no_args_is_help=True)
11
+
12
+ def cmd(fn=None, name=None):
13
+ """Register a command that reports ValueError cleanly and also accepts --json after the subcommand."""
14
+ if fn is None: return lambda f: cmd(f, name)
15
+ sig = inspect.signature(fn)
16
+ @functools.wraps(fn)
17
+ def run(*args, json_flag=False, **kwargs):
18
+ if json_flag and "ctx" in kwargs: kwargs["ctx"].obj["json"] = True
19
+ try: return fn(*args, **kwargs)
20
+ except ValueError as error:
21
+ typer.echo(f"orchestrate: {error}", err=True)
22
+ raise typer.Exit(1)
23
+ run.__signature__ = sig.replace(parameters=[*sig.parameters.values(), inspect.Parameter("json_flag", inspect.Parameter.KEYWORD_ONLY, default=typer.Option(False, "--json", hidden=True), annotation=bool)])
24
+ return app.command(name=name)(run)
25
+
26
+ @app.callback()
27
+ def main(ctx: typer.Context,
28
+ session: str = typer.Option(None, "--session", "-s", help="Orchestrate session id; defaults to the bound provider session"),
29
+ actor: str = typer.Option("agent", "--as", envvar="CONVOS_ORCHESTRATE_AS", help="Who is acting: agent, or a subagent name"),
30
+ scope: str = typer.Option(None, "--scope", envvar="CONVOS_ORCHESTRATE_SCOPE", help="Restrict writes to this item's subtree (subagents)"),
31
+ as_json: bool = typer.Option(False, "--json")):
32
+ ctx.obj = dict(session=session, actor=actor, scope=scope, json=as_json)
33
+
34
+ def out(ctx, value, text):
35
+ typer.echo(json.dumps(value, ensure_ascii=True, separators=(",", ":")) if ctx.obj["json"] else text)
36
+
37
+ def board(s):
38
+ """Plain-text board: the agent's own view of what the user sees."""
39
+ eff, meta = store.effective(s), s["session"]
40
+ def who(i): return f" ({i['actor']})" if i["actor"] not in ("agent", "user") else ""
41
+ def results(target, depth): return [f"{' '*depth} {a['id']:<4} {a['kind']}: {a['caption'] or a['name']}" for a in s["attachments"].values() if a["target"] == target]
42
+ marks = store.seen(meta["id"])
43
+ new = store.unread(s, marks)
44
+ # what the user has read, so the agent knows what it may archive: unseen rows stay, finished rows the user has read may go
45
+ def mark(i): return " (archived)" if i.get("archived") else " · unseen by the user" if i["id"] in new else " · archivable" if not i["parent"] and not store.blocked(s, i["id"], marks) else ""
46
+ def tree(parent, depth): return [line for i in s["items"].values() if i["parent"] == parent for line in [f"{' '*depth} {i['id']:<4} [{eff[i['id']]}] {i['title']}{who(i)}{mark(i)}", *results(i["id"], depth+2), *tree(i["id"], depth+1)]]
47
+ asks = [f" {a['id']} on {a['target'] or 'session'}: {a['text']}{' -> ' + ' | '.join(a['options']) if a['options'] else ''}" for a in store.open_asks(s)]
48
+ waiting = [store.describe(s, p) for p in store.pending(s)]
49
+ return "\n".join([f"{meta['id']} {meta['title']!r} [{store.status(s)}] {meta['provider']} {meta['cwd']}",
50
+ *([f"now: {s['activity']['text']}"] if s["activity"] else []),
51
+ *(tree(None, 0) or [" (no items yet: convos orchestrate add 'TITLE')"]), *(["session results:", *results(None, 0)] if results(None, 0) else []),
52
+ *(["open questions:", *asks] if asks else []), *(["undelivered user messages (run inbox):", *waiting] if waiting else [])])
53
+
54
+ def ident(ctx):
55
+ sid = store.resolve(ctx.obj["session"])
56
+ channels.record(sid)
57
+ return sid
58
+ def emit(ctx, kind, body="", **fields): return store.emit(ident(ctx), kind, ctx.obj["actor"], body, scope=ctx.obj["scope"], **fields)
59
+ def text_arg(value): return sys.stdin.read() if value == "-" else value
60
+
61
+ @cmd
62
+ def join(ctx: typer.Context, title: str = typer.Option(..., "--title", "-t", help="What this session is working on"),
63
+ cwd: str = typer.Option(".", "--cwd"), model: str = typer.Option(None, "--model"),
64
+ provider: str = typer.Option(None, "--provider"), provider_session: str = typer.Option(None, "--provider-session"),
65
+ parent: str = typer.Option(None, "--parent", help="SESSION:ITEM this session works for")):
66
+ """Register this agent session with orchestrate (idempotent per provider session)."""
67
+ detected = store.provider_identity()
68
+ provider, provider_session = provider or detected[0] or "unknown", provider_session or detected[1]
69
+ if provider_session and (sid := store.bindings().get(f"{provider}:{provider_session}")):
70
+ s = store.load(sid)
71
+ if s["closed"]: s = store.emit(sid, "reopen", ctx.obj["actor"])[0]
72
+ if s["session"]["title"] != title: s = store.emit(sid, "meta", ctx.obj["actor"], changes=dict(title=title))[0]
73
+ return out(ctx, dict(session=sid, joined=False), f"Already joined as {sid}.\n{board(s)}")
74
+ meta = dict(id=(sid := store.new_id()), title=title, provider=provider, provider_session=provider_session, model=model or os.environ.get("ANTHROPIC_MODEL"), effort=os.environ.get("CLAUDE_EFFORT"), cwd=str(Path(cwd).resolve()), origin="joined", parent=parent, skills=[])
75
+ s = store.emit(sid, "open", ctx.obj["actor"], "", session=meta)[0]
76
+ channels.record(sid)
77
+ hint = "" if provider_session else f"\nNo provider session id was detected: pass -s {sid} on every command, or export CONVOS_ORCHESTRATE_SESSION={sid}."
78
+ out(ctx, dict(session=sid, joined=True), f"Joined orchestrate as {sid}. The user sees your board in the orchestrate IDE.{hint}\n{board(s)}")
79
+
80
+ @cmd
81
+ def show(ctx: typer.Context):
82
+ """Print this session's board: items, statuses, open questions, undelivered user messages."""
83
+ s = store.load(ident(ctx))
84
+ out(ctx, dict(s, status=store.status(s), effective=store.effective(s)), board(s))
85
+
86
+ @cmd
87
+ def add(ctx: typer.Context, title: str, description: str = typer.Option("", "--description", "-d", help="Markdown; '-' reads stdin"),
88
+ status: str = typer.Option("planned", "--status"), parent: str = typer.Option(None, "--parent", "-p"),
89
+ again: bool = typer.Option(False, "--again", help="Add it even if a row with this title already sits under the same parent")):
90
+ """Add an item (one unit of work) to the board. Re-adding a title that is already under the same parent returns that row."""
91
+ body, parent, title = text_arg(description), parent or ctx.obj["scope"], title.strip()
92
+ # a re-run setup script must not grow a second board: the existing row's id comes back and nothing is written
93
+ if not again and (twin := next((i for i, x in store.load(ident(ctx))["items"].items() if x["parent"] == parent and x["title"] == title), None)):
94
+ return out(ctx, dict(item=twin, existing=True), f"{twin} already on the board: {title} (nothing added; --again adds another)")
95
+ s, e = emit(ctx, "item", body, parent=parent, title=title, status=status)
96
+ out(ctx, dict(item=e["item"]), f"{e['item']} [{status}] {title}")
97
+
98
+ @cmd(name="set")
99
+ def set_(ctx: typer.Context, item: str, status: str = typer.Option(None, "--status"), title: str = typer.Option(None, "--title"),
100
+ description: str = typer.Option(None, "--description", "-d", help="Markdown; '-' reads stdin")):
101
+ """Change an item's status, title, or description."""
102
+ changes = {k: v for k, v in dict(status=status, title=title).items() if v is not None}
103
+ if not changes and description is None: raise ValueError("Nothing to change: pass --status, --title, or --description")
104
+ # only real changes are written, so repeating a command neither adds history nor brings an archived row back
105
+ body, now = text_arg(description) if description is not None else "", store.load(ident(ctx))["items"].get(item)
106
+ if now: changes, description = {k: v for k, v in changes.items() if now[k] != v}, None if description is None or now["description"] == body else description
107
+ if now and not changes and description is None: return out(ctx, dict(item=item, changes={}), f"{item} [{now['status']}] {now['title']} (unchanged)")
108
+ s, e = emit(ctx, "update", body, item=item, changes=changes, description=description is not None)
109
+ out(ctx, dict(item=item, changes=changes), f"{item} [{s['items'][item]['status']}] {s['items'][item]['title']}")
110
+
111
+ @cmd
112
+ def archive(ctx: typer.Context, items: list[str] = typer.Argument(..., help="Top-level rows")):
113
+ """Archive finished rows the user has already looked at, once the work has moved on. Anything else is refused with the reason."""
114
+ emit(ctx, "archive", items=items, archived=True)
115
+ out(ctx, dict(archived=items), f"Archived {', '.join(items)}; anything new on them brings them back")
116
+
117
+ @cmd
118
+ def now(ctx: typer.Context, text: str = typer.Argument("", help="What you are doing right now; empty clears it")):
119
+ """Set the one-line live activity shown on the session."""
120
+ emit(ctx, "activity", text.strip())
121
+ out(ctx, dict(activity=text.strip()), f"now: {text.strip() or '(cleared)'}")
122
+
123
+ @cmd
124
+ def say(ctx: typer.Context, text: str, on: str = typer.Option(None, "--on", help="Item thread; omit for the session chat"),
125
+ reply: str = typer.Option(None, "--reply", help="Comment id you are answering")):
126
+ """Write in a chat thread: the session's top-level chat, or an item's thread."""
127
+ sid = ident(ctx)
128
+ target = on if on or not reply else store.load(sid)["comments"].get(reply, {}).get("target")
129
+ s, e = store.emit(sid, "comment", ctx.obj["actor"], text_arg(text), scope=ctx.obj["scope"], target=target or (ctx.obj["scope"] if not on and not reply else None), reply_to=reply)
130
+ out(ctx, dict(comment=e["comment"]), f"{e['comment']} posted on {e['target'] or 'session'}")
131
+
132
+ KINDS = {"image/": "image", "video/": "video", "application/pdf": "pdf", "text/markdown": "markdown", "text/": "text"}
133
+ def kind_of(name, mime): return "markdown" if name.lower().endswith((".md", ".markdown")) else next((k for prefix, k in KINDS.items() if mime.startswith(prefix)), "file")
134
+
135
+ @cmd
136
+ def attach(ctx: typer.Context, source: str = typer.Argument(None, help="File path or URL"), on: str = typer.Option(None, "--on", help="Item; omit for the session"),
137
+ text: str = typer.Option(None, "--text", help="Inline markdown result instead of a file; '-' reads stdin"),
138
+ name: str = typer.Option(None, "--name"), caption: str = typer.Option("", "--caption")):
139
+ """Append a result to an item: image, PDF, markdown, any file, or a link."""
140
+ if (source is None) == (text is None): raise ValueError("Pass exactly one of SOURCE or --text")
141
+ target = on or ctx.obj["scope"]
142
+ if source and source.split(":", 1)[0] in ("http", "https"):
143
+ meta, blob = dict(kind="link", name=name or source, mime=None, size=None, hash=None, url=source), None
144
+ else:
145
+ path = None if text is not None else Path(source).expanduser()
146
+ data_name = name or (path.name if path else "result.md")
147
+ if text is not None:
148
+ temp = store.root()/"tmp"/f"{os.getpid()}.md"
149
+ store.core.secure_dir(temp.parent)
150
+ temp.write_text(text_arg(text))
151
+ path = temp
152
+ if not path.is_file(): raise ValueError(f"No such file: {source}")
153
+ blob, size = store.store_file(path)
154
+ mime = mimetypes.guess_type(data_name)[0] or ("text/markdown" if text is not None else "application/octet-stream")
155
+ meta = dict(kind=kind_of(data_name, mime), name=data_name, mime=mime, size=size, hash=blob.name, url=None)
156
+ if text is not None: path.unlink()
157
+ s, e = emit(ctx, "attach", caption, blob=blob, target=target, file=meta)
158
+ out(ctx, dict(attachment=e["attachment"], kind=meta["kind"]), f"{e['attachment']} {meta['kind']} {meta['name']} attached to {target or 'session'}")
159
+
160
+ @cmd
161
+ def ask(ctx: typer.Context, question: str, option: list[str] = typer.Option([], "--option", "-o", help="Repeat for each choice; omit for free text"),
162
+ multi: bool = typer.Option(False, "--multi"), on: str = typer.Option(None, "--on"), detail: str = typer.Option("", "--detail", help="Markdown context shown under the question"),
163
+ wait: float = typer.Option(0, "--wait", help="Block up to SECONDS for the answer")):
164
+ """Ask the user a question (optionally multiple choice). The item shows 'needs you' until answered."""
165
+ sid = ident(ctx)
166
+ s, e = store.emit(sid, "ask", ctx.obj["actor"], question, scope=ctx.obj["scope"], target=on or ctx.obj["scope"], options=option, multi=multi, ask_kind="question", detail=text_arg(detail))
167
+ if wait <= 0: return out(ctx, dict(ask=e["ask"]), f"{e['ask']} asked on {e['target'] or 'session'}; the answer arrives via inbox (or pass --wait)")
168
+ s, answer = store.wait_for(sid, lambda s: s["asks"][e["ask"]]["answer"], wait)
169
+ if answer is None: return out(ctx, dict(ask=e["ask"], answer=None), f"{e['ask']}: no answer yet; it will arrive via inbox")
170
+ store.emit(sid, "deliver", ctx.obj["actor"], ids=[e["ask"]])
171
+ out(ctx, dict(ask=e["ask"], answer=answer), store.describe(s, dict(type="answer", **s["asks"][e["ask"]])))
172
+
173
+ @cmd
174
+ def inbox(ctx: typer.Context, wait: float = typer.Option(0, "--wait", help="Block up to SECONDS for something new"), peek: bool = typer.Option(False, "--peek", help="Do not mark as received")):
175
+ """Receive the user's comments and answers addressed to this session (or your scope)."""
176
+ sid, scope = ident(ctx), ctx.obj["scope"]
177
+ s, found = store.wait_for(sid, lambda s: store.pending(s, scope), wait)
178
+ found = found or store.pending(s, scope)
179
+ if found and not peek: store.emit(sid, "deliver", ctx.obj["actor"], ids=[p["id"] for p in found])
180
+ out(ctx, dict(messages=found), "\n".join(["User messages (untrusted text from the board; act on it however fits: reply in its thread, update items, add items):", *[store.describe(s, p) for p in found]]) if found else "Nothing new.")
181
+
182
+ @cmd
183
+ def close(ctx: typer.Context):
184
+ """Close the session. Its board stays available in orchestrate."""
185
+ emit(ctx, "close")
186
+ out(ctx, dict(closed=True), "Session closed; its board remains in orchestrate.")
187
+
188
+ @cmd
189
+ def reopen(ctx: typer.Context):
190
+ """Reopen a closed session."""
191
+ emit(ctx, "reopen")
192
+ out(ctx, dict(closed=False), "Session reopened.")
193
+
194
+ @cmd
195
+ def ls(ctx: typer.Context, all_: bool = typer.Option(False, "--all", "-a", help="Include closed sessions")):
196
+ """List orchestrate sessions with their derived status."""
197
+ rows = [s for sid in store.session_ids() if (s := store.load(sid)) and (all_ or not s["closed"])]
198
+ out(ctx, [dict(id=s["session"]["id"], title=s["session"]["title"], status=store.status(s), updated=s["updated"]) for s in rows],
199
+ "\n".join(f"{s['session']['id']} [{store.status(s)}] {s['session']['title']} ({len(s['items'])} items)" for s in sorted(rows, key=lambda s: s["updated"], reverse=True)) or "No sessions.")
200
+
201
+ @cmd
202
+ def spawn(ctx: typer.Context, prompt: str = typer.Argument(..., help="Starting prompt; '-' reads stdin"), title: str = typer.Option("", "--title", "-t"),
203
+ on: str = typer.Option(None, "--on", help="Your item the child session works for"), cwd: str = typer.Option(".", "--cwd"),
204
+ model: str = typer.Option("claude-opus-5-5", "--model"), effort: str = typer.Option(None, "--effort"),
205
+ skill: list[str] = typer.Option([], "--skill", help="Repeat; orchestrate is always on"), permission: str = typer.Option("default", "--permission")):
206
+ """Start a child session (headless, managed by the IDE) working for this session. Needs the IDE server running."""
207
+ from .server import request
208
+ sid = ident(ctx)
209
+ if on: store.load(sid)["items"][on]
210
+ child = request("/api/spawn", dict(title=title, cwd=str(Path(cwd).resolve()), prompt=text_arg(prompt), model=model, effort=effort, skills=skill, permission=permission, parent=f"{sid}:{on}" if on else sid))["session"]
211
+ out(ctx, dict(session=child), f"Started child session {child}. Watch it with: convos orchestrate -s {child} show")
212
+
213
+ @cmd(name="open")
214
+ def open_(ctx: typer.Context, port: int = typer.Option(7311, "--port")):
215
+ """Open the orchestrate IDE in your browser, starting its local server if needed."""
216
+ from .server import launch
217
+ url = launch(port)
218
+ out(ctx, dict(url=url), f"Orchestrate is open in your browser: {url}")
219
+
220
+ @cmd
221
+ def serve(port: int = typer.Option(7311, "--port"), browser: bool = typer.Option(True, "--browser/--no-browser")):
222
+ """Run the IDE server in the foreground."""
223
+ from .server import serve as run
224
+ run(port, browser)
225
+
226
+ @cmd
227
+ def install(ctx: typer.Context, remove: bool = typer.Option(False, "--remove")):
228
+ """Install (or remove) the orchestrate skill for Claude Code (/orchestrate) and Codex ($orchestrate)."""
229
+ from .setup import install as run
230
+ results = run(remove)
231
+ out(ctx, results, "\n".join(f"{r['provider']}: {'installed' if r['installed'] else 'removed'} {r['skill']}" for r in results))
232
+
233
+ def register(parent): parent.add_typer(app, name="orchestrate")
234
+
235
+ # Core loads product entry points during import; registration must already exist.
236
+ from . import store, channels
@@ -0,0 +1,52 @@
1
+ """Provider channels that put the user's words into a joined session.
2
+
3
+ Claude Code: each session binds an inbox socket and exports its path and token only to its own hooks and
4
+ Bash commands. The agent's own `convos orchestrate` calls record them here as local working state
5
+ (never in the archive), so orchestrate can post `{"type":"user",...}` lines the session treats as its own
6
+ child's. Codex: `codex queue --thread ID --message TEXT` queues for the thread.
7
+ """
8
+ import json, os, shutil, socket, subprocess
9
+ from pathlib import Path
10
+ from . import store
11
+
12
+ def endpoint_path(sid): return store.sdir(sid)/"endpoint.json"
13
+
14
+ def record(sid):
15
+ """Called from the agent's own shell: remember how to reach this live Claude Code session."""
16
+ if not (sock := os.environ.get("CLAUDE_CODE_MESSAGING_SOCKET")) or not (s := store.peek(sid)): return
17
+ if s["session"]["origin"] == "gui" or s["session"].get("provider_session") != os.environ.get("CLAUDE_CODE_SESSION_ID"): return
18
+ value = dict(socket=sock, token=os.environ.get("CLAUDE_CODE_MESSAGING_TOKEN"), pid=int(os.environ.get("CLAUDE_PID") or 0) or None, session=os.environ.get("CLAUDE_CODE_SESSION_ID"))
19
+ try: current = json.loads(endpoint_path(sid).read_text())
20
+ except (OSError, ValueError): current = None
21
+ if current != value:
22
+ store.core.atomic_json(endpoint_path(sid), value)
23
+ os.chmod(endpoint_path(sid), 0o600)
24
+
25
+ def endpoint(s):
26
+ try: value = json.loads(endpoint_path(s["session"]["id"]).read_text())
27
+ except (OSError, ValueError): return None
28
+ if value["session"] and s["session"].get("provider_session") and value["session"] != s["session"]["provider_session"]: return None
29
+ try: (os.kill(value["pid"], 0) if value["pid"] else None)
30
+ except ProcessLookupError: return None
31
+ except PermissionError: pass
32
+ return value if Path(value["socket"]).is_socket() else None
33
+
34
+ def reachable(s):
35
+ meta = s["session"]
36
+ if meta["origin"] == "gui": return False
37
+ if meta["provider"] == "claude-code": return endpoint(s) is not None
38
+ return meta["provider"] == "codex" and bool(meta.get("provider_session")) and shutil.which("codex") is not None
39
+
40
+ def push(sid, meta, text):
41
+ s = store.load(sid)
42
+ if meta["provider"] == "claude-code" and (point := endpoint(s)):
43
+ lines = [*([dict(type="auth", token=point["token"])] if point["token"] else []), dict(type="user", message=dict(role="user", content=text))]
44
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
45
+ conn.settimeout(5)
46
+ conn.connect(point["socket"])
47
+ conn.sendall("".join(json.dumps(l) + "\n" for l in lines).encode())
48
+ return "claude-socket"
49
+ if meta["provider"] == "codex" and reachable(s):
50
+ subprocess.run(["codex", "queue", "--thread", meta["provider_session"], "--message", text], check=True, capture_output=True, timeout=30)
51
+ return "codex-queue"
52
+ return None
@@ -0,0 +1,52 @@
1
+ """Minimal stdio MCP server for headless sessions: permission prompts become questions on the board.
2
+
3
+ Claude Code calls `permission` (via --permission-prompt-tool) whenever a tool needs approval. The call
4
+ blocks until the user answers in the IDE; "Allow always" is remembered for that tool in this session.
5
+ """
6
+ import json, os, sys
7
+ from . import store
8
+
9
+ TOOL = dict(name="permission", description="Ask the user in Convos Orchestrate to allow or deny a tool call.",
10
+ inputSchema=dict(type="object", properties=dict(tool_name=dict(type="string"), input=dict(type="object"), tool_use_id=dict(type="string")), required=["tool_name", "input"]))
11
+ OPTIONS = ["Allow", "Allow always", "Deny"]
12
+
13
+ def preview(name, data):
14
+ if name == "Bash": return f"```sh\n{data.get('command', '')}\n```\n{data.get('description', '')}"
15
+ if "file_path" in data: return f"`{data['file_path']}`\n\n```\n{json.dumps({k: v for k, v in data.items() if k != 'file_path'}, indent=1)[:3000]}\n```"
16
+ return f"```json\n{json.dumps(data, indent=1)[:4000]}\n```"
17
+
18
+ def decide(sid, args):
19
+ name, data = args["tool_name"], args.get("input", {})
20
+ if name in store.load(sid)["session"].get("allow", []): return dict(behavior="allow", updatedInput=data)
21
+ s, e = store.emit(sid, "ask", "agent", f"Allow {name}?", target=None, options=OPTIONS, multi=False, ask_kind="permission", detail=preview(name, data))
22
+ s, answer = store.wait_for(sid, lambda s: s["asks"][e["ask"]]["answer"] or s["asks"][e["ask"]]["cancelled"], 6*3600)
23
+ if not answer:
24
+ store.emit(sid, "cancel", "orchestrate", ask=e["ask"])
25
+ return dict(behavior="deny", message="No answer from the user in orchestrate within 6 hours.")
26
+ store.emit(sid, "deliver", "orchestrate", ids=[e["ask"]])
27
+ choice = (answer.get("choices") or ["Deny"])[0] if isinstance(answer, dict) else "Deny"
28
+ if choice == "Allow always": store.emit(sid, "meta", "user", changes=dict(allow=sorted({*s["session"].get("allow", []), name})))
29
+ if choice.startswith("Allow"): return dict(behavior="allow", updatedInput=data)
30
+ return dict(behavior="deny", message=f"The user denied {name} in orchestrate." + (f" Note: {answer['text']}" if answer.get("text") else ""))
31
+
32
+ def main():
33
+ sid = os.environ["CONVOS_ORCHESTRATE_SESSION"]
34
+ def reply(rid, result=None, error=None):
35
+ sys.stdout.write(json.dumps(dict(jsonrpc="2.0", id=rid, **(dict(error=error) if error else dict(result=result)))) + "\n")
36
+ sys.stdout.flush()
37
+ for line in sys.stdin:
38
+ if not line.strip(): continue
39
+ req = json.loads(line)
40
+ method, rid = req.get("method"), req.get("id")
41
+ if rid is None: continue
42
+ if method == "initialize": reply(rid, dict(protocolVersion=req["params"]["protocolVersion"], capabilities=dict(tools={}), serverInfo=dict(name="orchestrate", version="1")))
43
+ elif method == "tools/list": reply(rid, dict(tools=[TOOL]))
44
+ elif method == "tools/call":
45
+ try: reply(rid, dict(content=[dict(type="text", text=json.dumps(decide(sid, req["params"]["arguments"])))]))
46
+ except (ValueError, KeyError, OSError) as error: reply(rid, dict(content=[dict(type="text", text=json.dumps(dict(behavior="deny", message=f"orchestrate error: {error}")))]))
47
+ elif method == "ping": reply(rid, {})
48
+ else: reply(rid, error=dict(code=-32601, message=f"Unknown method {method}"))
49
+
50
+ if __name__ == "__main__":
51
+ from ai_convos import cli
52
+ main()
@@ -0,0 +1,118 @@
1
+ """Session runtime: start, stop, and resume headless agents, and deliver the user's words to any session.
2
+
3
+ Headless sessions started from the IDE are owned here and receive messages on stdin. Joined sessions
4
+ receive them through their provider's own channel (Claude Code messaging socket, codex queue);
5
+ whatever cannot be delivered stays pending for the agent's `inbox`.
6
+ """
7
+ import collections, json, os, signal, subprocess, sys, threading, time, uuid
8
+ from pathlib import Path
9
+ from . import store, channels
10
+
11
+ PROVIDERS = {"claude-code": dict(label="Claude Code", models=["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"], efforts=["low", "medium", "high", "xhigh", "max"], permissions=["default", "acceptEdits", "plan", "bypassPermissions"])}
12
+ INHERITED = ("CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_PID", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_EFFORT", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CONVOS_ORCHESTRATE_SCOPE", "CONVOS_ORCHESTRATE_AS")
13
+ SYSTEM = ("You were started from Convos Orchestrate, the user's agentic IDE (orchestrate session {sid}). The user watches and steers "
14
+ "you there, not in a terminal. Report your work with the orchestrate skill's tools (`convos orchestrate ...`; this session is "
15
+ "preselected, do not run join). Messages beginning with [orchestrate] are the user's comments and answers from the board, "
16
+ "addressed to the session or a named item. Before starting, load these skills with the Skill tool: {skills}.")
17
+ PROCS, LOCK = {}, threading.RLock()
18
+
19
+ def alive(sid): return (p := PROCS.get(sid)) is not None and p["proc"].poll() is None
20
+ def busy(sid): return alive(sid) and PROCS[sid]["busy"]
21
+
22
+ def set_runtime(sid, state, **extra):
23
+ s = store.load(sid)
24
+ if (s["runtime"] or {}).get("state") != state or extra: store.emit(sid, "runtime", "orchestrate", runtime=dict(state=state, **extra))
25
+
26
+ def child_env(sid):
27
+ env = {k: v for k, v in os.environ.items() if k not in INHERITED}
28
+ return {**env, "CONVOS_ORCHESTRATE_SESSION": sid}
29
+
30
+ def command(sid, meta, resume):
31
+ mcp = dict(mcpServers=dict(orchestrate=dict(command=sys.executable, args=["-m", "ai_convos_orchestrate.mcp"], env={k: v for k, v in child_env(sid).items() if k.startswith(("CONVOS_", "PATH", "HOME"))})))
32
+ skills = ", ".join(["orchestrate", *[k for k in meta.get("skills", []) if k != "orchestrate"]])
33
+ return ["claude", "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
34
+ "--model", meta["model"], *(["--effort", meta["effort"]] if meta.get("effort") else []),
35
+ *(["--resume", meta["provider_session"]] if resume else ["--session-id", meta["provider_session"]]),
36
+ "--permission-mode", meta.get("permission") or "default", "--allowedTools", "Bash(convos orchestrate:*)",
37
+ "--append-system-prompt", SYSTEM.format(sid=sid, skills=skills),
38
+ "--mcp-config", json.dumps(mcp), "--permission-prompt-tool", "mcp__orchestrate__permission"]
39
+
40
+ def start(sid, text=None):
41
+ """Start (or resume) a managed session's process; optionally send it a user turn."""
42
+ with LOCK:
43
+ if not alive(sid):
44
+ s = store.load(sid)
45
+ meta, resume = s["session"], s["runtime"] is not None and s["runtime"].get("started")
46
+ if meta["origin"] != "gui": raise ValueError("Only sessions started from orchestrate can be started here; joined sessions run in their own terminal")
47
+ if meta["provider"] not in PROVIDERS: raise ValueError(f"Provider {meta['provider']} cannot be started headless yet")
48
+ if not Path(meta["cwd"]).is_dir(): raise ValueError(f"Working directory is missing: {meta['cwd']}")
49
+ log = open(store.sdir(sid)/"stderr.log", "ab")
50
+ proc = subprocess.Popen(command(sid, meta, resume), cwd=meta["cwd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, env=child_env(sid), start_new_session=True)
51
+ PROCS[sid] = dict(proc=proc, busy=False, log=log)
52
+ threading.Thread(target=read, args=(sid, proc), daemon=True, name=f"orchestrate-{sid}").start()
53
+ set_runtime(sid, "idle", started=True, pid=proc.pid)
54
+ if text: send(sid, text)
55
+
56
+ def send(sid, text):
57
+ with LOCK:
58
+ proc = PROCS[sid]["proc"]
59
+ proc.stdin.write((json.dumps(dict(type="user", message=dict(role="user", content=[dict(type="text", text=text)]))) + "\n").encode())
60
+ proc.stdin.flush()
61
+ PROCS[sid]["busy"] = True
62
+ set_runtime(sid, "working", started=True, pid=proc.pid)
63
+
64
+ def read(sid, proc):
65
+ """The board is what the agent reports; its raw output only tells us when a turn ends."""
66
+ for raw in proc.stdout:
67
+ try: event = json.loads(raw)
68
+ except ValueError: continue
69
+ if event.get("type") == "result":
70
+ PROCS[sid]["busy"] = False
71
+ set_runtime(sid, "idle", started=True, pid=proc.pid, cost=event.get("total_cost_usd"), turns=event.get("num_turns"))
72
+ code = proc.wait()
73
+ with LOCK:
74
+ if PROCS.get(sid, {}).get("proc") is proc: PROCS.pop(sid)["log"].close()
75
+ set_runtime(sid, "stopped" if code in (0, -signal.SIGTERM, -signal.SIGINT, 128+signal.SIGTERM, 128+signal.SIGINT) else "failed", started=True, code=code)
76
+
77
+ def stop(sid):
78
+ with LOCK: proc = PROCS.get(sid, {}).get("proc")
79
+ if proc is None or proc.poll() is not None: return set_runtime(sid, "stopped", started=True)
80
+ os.killpg(proc.pid, signal.SIGTERM)
81
+ try: proc.wait(8)
82
+ except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL)
83
+
84
+ def settle():
85
+ """After a reboot or server restart, no managed process survives: record that truthfully."""
86
+ for sid in store.session_ids():
87
+ s = store.load(sid)
88
+ if s["session"]["origin"] == "gui" and (s["runtime"] or {}).get("state") in ("working", "idle", "starting") and not alive(sid):
89
+ store.emit(sid, "runtime", "orchestrate", runtime={**{k: v for k, v in s["runtime"].items() if k != "at"}, "state": "stopped"})
90
+
91
+ def spawn(title, cwd, prompt, model, effort=None, skills=(), permission="default", parent=None, provider="claude-code"):
92
+ if provider not in PROVIDERS: raise ValueError(f"Unknown provider {provider}")
93
+ if model not in PROVIDERS[provider]["models"]: raise ValueError(f"Unknown model {model}")
94
+ if not Path(cwd).expanduser().is_dir(): raise ValueError(f"No such directory: {cwd}")
95
+ if parent:
96
+ psid, _, pitem = parent.partition(":")
97
+ if pitem and pitem not in store.load(psid)["items"]: raise ValueError(f"Parent item {parent} does not exist")
98
+ meta = dict(id=(sid := store.new_id()), title=title.strip() or prompt.strip().splitlines()[0][:80], provider=provider, provider_session=str(uuid.uuid4()), model=model, effort=effort or None, cwd=str(Path(cwd).expanduser().resolve()), origin="gui", parent=parent or None, skills=sorted(set(skills) | {"orchestrate"}), permission=permission)
99
+ store.emit(sid, "open", "user", prompt, session=meta)
100
+ start(sid, prompt)
101
+ return sid
102
+
103
+ def message(s, found):
104
+ who = "from the user" if all(p["actor"] == "user" or p["type"] == "answer" for p in found) else "on your board"
105
+ return "\n".join([f"[orchestrate] {len(found)} new {who} (session {s['session']['id']}):", *[store.describe(s, p) for p in found]])
106
+
107
+ DELIVERING = collections.defaultdict(threading.Lock)
108
+
109
+ def deliver(sid):
110
+ """Route undelivered user words to the session exactly once. Returns the channel used, or 'pending' when the agent must pull."""
111
+ with DELIVERING[sid]:
112
+ s = store.load(sid)
113
+ if not (found := store.pending(s)): return None
114
+ text, meta = message(s, found), s["session"]
115
+ if meta["origin"] == "gui": (start(sid, text), route := "stdin")
116
+ elif not (route := channels.push(sid, meta, text)): return "pending"
117
+ store.emit(sid, "deliver", "orchestrate", ids=[p["id"] for p in found], channel=route)
118
+ return route