deskd 0.1.0__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 (33) hide show
  1. deskd-0.1.0/.env.example +63 -0
  2. deskd-0.1.0/.gitignore +28 -0
  3. deskd-0.1.0/LICENSE +21 -0
  4. deskd-0.1.0/PKG-INFO +193 -0
  5. deskd-0.1.0/README.md +165 -0
  6. deskd-0.1.0/docs/design.md +173 -0
  7. deskd-0.1.0/docs/roadmap.md +283 -0
  8. deskd-0.1.0/docs/security.md +75 -0
  9. deskd-0.1.0/pyproject.toml +71 -0
  10. deskd-0.1.0/scripts/cron/wake_orchestrator.sh +219 -0
  11. deskd-0.1.0/scripts/session_hook.py +385 -0
  12. deskd-0.1.0/skills/agent-orchestration/KNOWLEDGE.md +44 -0
  13. deskd-0.1.0/skills/agent-orchestration/SKILL.md +136 -0
  14. deskd-0.1.0/skills/agent-orchestration/references/EVOLUTION_PROTOCOL.md +47 -0
  15. deskd-0.1.0/skills/agent-orchestration/references/MEETING_PROTOCOL.md +252 -0
  16. deskd-0.1.0/skills/agent-orchestration/references/PLAYBOOK_PATTERN.md +52 -0
  17. deskd-0.1.0/skills/agent-orchestration/references/SESSION_LIFECYCLE.md +68 -0
  18. deskd-0.1.0/skills/agent-orchestration/references/SUPERVISOR_IDENTITY.md +114 -0
  19. deskd-0.1.0/skills/agent-orchestration/references/WAKE_ORCHESTRATION.md +120 -0
  20. deskd-0.1.0/skills/agent-orchestration/scripts/knowledge_txn.py +172 -0
  21. deskd-0.1.0/src/deskd/__init__.py +98 -0
  22. deskd-0.1.0/src/deskd/__main__.py +12 -0
  23. deskd-0.1.0/src/deskd/auth.py +668 -0
  24. deskd-0.1.0/src/deskd/cli.py +616 -0
  25. deskd-0.1.0/src/deskd/config.py +242 -0
  26. deskd-0.1.0/src/deskd/mailbox.py +971 -0
  27. deskd-0.1.0/src/deskd/meetings.py +2163 -0
  28. deskd-0.1.0/src/deskd/orchestration.py +2439 -0
  29. deskd-0.1.0/src/deskd/web/__init__.py +16 -0
  30. deskd-0.1.0/src/deskd/web/app.py +224 -0
  31. deskd-0.1.0/src/deskd/web/static/agent.html +147 -0
  32. deskd-0.1.0/src/deskd/web/static/board.html +242 -0
  33. deskd-0.1.0/src/deskd/web/static/meetings.html +175 -0
@@ -0,0 +1,63 @@
1
+ # deskd configuration — copy to .env and fill in. NEVER commit .env.
2
+
3
+ # Coordination DB (SQLite, WAL). Default: ./data/deskd.db
4
+ DESKD_DB=
5
+ # Base dir used to derive the default DB path. Default: the process cwd.
6
+ DESKD_HOME=
7
+
8
+ # --- supervisor console auth -------------------------------------------------
9
+ # simple | signed | hybrid.
10
+ # simple — one shared access code over a trusted local network. Convenient,
11
+ # and only as strong as the code: it proves someone knows a secret,
12
+ # not who acted.
13
+ # signed — short-lived Ed25519 assertions signed on a trusted device. The
14
+ # public key is read from /etc/deskd/supervisor_ed25519.pub, which
15
+ # must be root-owned; that path is fixed and deliberately NOT
16
+ # settable here, because an agent that could repoint it could mint
17
+ # its own identity. Keep the private key OFF this host.
18
+ # hybrid — both accepted.
19
+ # NOTE: the default (simple) leaves the signed path disabled.
20
+ DESKD_SUPERVISOR_AUTH_MODE=simple
21
+
22
+ # simple mode: the access code. Generate a strong one, e.g.
23
+ # python3 -c "import secrets;print(secrets.token_urlsafe(18))"
24
+ # If unset, the server generates one at startup and prints it to its terminal.
25
+ # NEVER hardcode this into any client/static file — that publishes the credential.
26
+ DESKD_SUPERVISOR_ACCESS_CODE=
27
+
28
+ # --- web console -------------------------------------------------------------
29
+ # `deskd serve` binds loopback by default: the console carries the supervisor
30
+ # adapter, so exposing it on a network must be a deliberate act.
31
+ DESKD_HOST=127.0.0.1
32
+ DESKD_PORT=8000
33
+
34
+ # --- escalation channels -----------------------------------------------------
35
+ # deskd ships NO network code and no channels of its own: an escalation always
36
+ # lands in the `outbox` (a ledger row the console renders), and a host registers
37
+ # anything richer at startup:
38
+ #
39
+ # from deskd.meetings import CallableChannel, register_channel
40
+ # register_channel(CallableChannel(
41
+ # "discord", send=lambda subject, text: post_webhook(text),
42
+ # available=lambda: bool(os.environ.get("MYAPP_DISCORD_WEBHOOK")),
43
+ # ))
44
+ #
45
+ # Credentials for those channels are the host's business and belong in the
46
+ # host's own environment, not here.
47
+
48
+ # --- wake driver (scripts/cron/wake_orchestrator.sh) -------------------------
49
+ # The driver is DRY-RUN unless this is exactly 1. Watch logs/wake.log first.
50
+ DESKD_WAKE_EXECUTE=0
51
+ # DESKD_AGENT_CMD=claude
52
+ # DESKD_WAKE_MODEL=claude-opus-4-8
53
+ # DESKD_WAKE_TIMEOUT=1800
54
+ # Tools a woken session may use. Append your host's own tools here — the engine
55
+ # grants nothing by itself.
56
+ # DESKD_WAKE_ALLOWED_TOOLS=Bash,Read,Write,Edit,Glob,Grep,Skill,ToolSearch,TodoWrite,WebSearch,WebFetch
57
+ # DESKD_PYTHON= # interpreter; defaults to ./.venv/bin/python, else python3
58
+ # DESKD_BIN= # deskd CLI; defaults to ./.venv/bin/deskd, else `deskd` on PATH
59
+ # DESKD_LOG_DIR=
60
+
61
+ # Exported by the driver into a spawned/resumed session, and read by the
62
+ # PostToolUse hook (scripts/session_hook.py) to know which role it is serving.
63
+ # DESKD_ROLE=
deskd-0.1.0/.gitignore ADDED
@@ -0,0 +1,28 @@
1
+ # --- data / secrets: NEVER commit ---
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ *.db
6
+ *.db-wal
7
+ *.db-shm
8
+ data/
9
+ logs/
10
+ *.log
11
+
12
+ # --- python ---
13
+ __pycache__/
14
+ *.py[cod]
15
+ .venv/
16
+ venv/
17
+ build/
18
+ dist/
19
+ *.egg-info/
20
+ .pytest_cache/
21
+ .mypy_cache/
22
+ .ruff_cache/
23
+
24
+ # --- os / editor ---
25
+ .DS_Store
26
+ *.swp
27
+ .idea/
28
+ .vscode/
deskd-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hongdp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
deskd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: deskd
3
+ Version: 0.1.0
4
+ Summary: A domain-agnostic orchestration engine for multi-agent desks: presence, a unified inbox, bounded meetings, and a wake ladder that proves delivery.
5
+ Project-URL: Homepage, https://github.com/hongdp/deskd
6
+ Project-URL: Documentation, https://github.com/hongdp/deskd/blob/main/docs/design.md
7
+ Project-URL: Source, https://github.com/hongdp/deskd
8
+ Author: deskd contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,multi-agent,orchestration,scheduling,sqlite
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
19
+ Classifier: Topic :: System :: Distributed Computing
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: cryptography>=41
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Provides-Extra: web
25
+ Requires-Dist: fastapi>=0.110; extra == 'web'
26
+ Requires-Dist: uvicorn[standard]>=0.27; extra == 'web'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # deskd
30
+
31
+ **An orchestration engine for multi-agent desks.** deskd owns the part that is
32
+ hard and domain-agnostic: knowing which agents are alive, what they're doing,
33
+ what's queued for them, and — the difficult bit — **reliably waking the right
34
+ agent at the right time and proving the message actually landed.**
35
+
36
+ Your agents do the domain work. deskd does everything else.
37
+
38
+ > Built for headless [Claude Code](https://claude.com/claude-code) agents
39
+ > (`claude -p` + a `PostToolUse` hook), but the engine is a plain Python package
40
+ > over SQLite with no hard dependency on any particular agent runtime.
41
+
42
+ ---
43
+
44
+ ## Why
45
+
46
+ Multi-agent systems usually rot in the same three places:
47
+
48
+ 1. **Agents poll.** Every agent runs its own `sleep`/wake loop, burning tokens
49
+ to discover there's nothing to do — and still missing the thing that mattered.
50
+ 2. **Messages vanish.** "I sent it" ≠ "they read it." Nothing distinguishes
51
+ *notified* from *read*, so a stuck message is invisible until someone notices
52
+ hours later.
53
+ 3. **Nobody knows what's running.** Two sessions of the same role stomp each
54
+ other; a crashed agent looks identical to an idle one.
55
+
56
+ deskd's position: **agents must never manage their own waking.** They end their
57
+ turn and the orchestrator wakes them — on a timer, on a calendar, on a custom
58
+ watcher, on a message, or on an escalation ladder when a wake doesn't land.
59
+
60
+ ## What you get
61
+
62
+ | | |
63
+ |---|---|
64
+ | **Presence** | One live session per role, enforced by a role-scoped `flock`. Heartbeats from the in-session hook; crash-safe (the kernel releases the lock). |
65
+ | **Unified inbox** | Every notification — alerts, signals, system events, meeting messages — lands in one queue per role, with per-key dedup so a re-firing alert never piles up. |
66
+ | **Wake orchestration** | Collect demand → route by presence → record the attempt → **verify the loop closed** → escalate. A wake that doesn't land climbs: in-session hook → resume → spawn → human (Discord/email) → a red badge on the supervisor console that never times out. |
67
+ | **Self-service wake hooks** | An agent registers its own wakes: `--at` (one-shot), `--every` (interval), `--cron` (calendar, DST-correct), or `--probe` (**your own watcher function** — return a dict and it wakes you). |
68
+ | **Delivery ledger** | Per message × recipient: `queued → notified → read`. Past SLA and unread with nobody reacting = **`overdue`** — surfaced red. Rows are a projection of durable messages, so a delivery can't be silently lost. |
69
+ | **Bounded meetings** | Multi-agent meetings with check-in/quorum, mandatory 1:1 replies with an SLA, message budgets, and a mutual termination handshake. Bounded by construction — no infinite agent chatter. |
70
+ | **Cross-session tasks** | Work items that outlive a session. Soft deadlines (`due_at`) sort to the top but **never wake anyone**; only `priority=urgent` does. |
71
+ | **Session lifecycle** | Intraday continuity, cross-day rollover: wind the old session down with a handoff, start fresh the next day. |
72
+ | **Supervisor console** | A web board (live status + queue + hooks + wake activity), a per-agent detail page with full execution history, and a meetings console — behind an access-code or Ed25519 trusted-device gate. |
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ pip install -e ".[web]" # engine + web console
78
+ ```
79
+
80
+ ## Quickstart
81
+
82
+ Describe your desk in a module that defines `configure_deskd()`:
83
+
84
+ ```python
85
+ # myapp/desk.py
86
+ from deskd.config import RoleSpec, PromptBuilder, configure
87
+
88
+ class MyPrompts(PromptBuilder):
89
+ def bootstrap(self, role: str) -> str:
90
+ return f"Load the myapp skill, declare role={role}, follow its playbook."
91
+
92
+ def configure_deskd(): # deskd calls this at startup
93
+ configure(
94
+ roles=(
95
+ RoleSpec("researcher", "Researcher", ("research", "review")),
96
+ RoleSpec("operator", "Operator", ("execution",), {"can_execute": True}),
97
+ ),
98
+ timezone="America/New_York",
99
+ inbox_sources=("alert", "signal", "system", "meeting", "supervisor"),
100
+ probe_allowlist=("myapp.watchers",), # empty = no probes may run
101
+ prompt_builder=MyPrompts(),
102
+ )
103
+ ```
104
+
105
+ Point deskd at it with **`DESKD_CONFIG_MODULE`**. Every deskd process — the CLI,
106
+ `deskd serve`, the cron driver — imports that module and calls `configure_deskd()`
107
+ before it touches the engine, so your roles are registered everywhere. Without it
108
+ a deskd process starts empty (no roles) and every role-scoped command is rejected.
109
+
110
+ ```bash
111
+ export DESKD_CONFIG_MODULE=myapp.desk # (myapp must be importable — on PYTHONPATH)
112
+
113
+ deskd serve # supervisor console on 127.0.0.1:8000
114
+ deskd status set --role operator --activity "watching the queue"
115
+ deskd inbox enqueue --for operator --source alert --title "threshold crossed" --priority urgent
116
+ deskd wake sources --role operator # what can wake me, and how to change it
117
+ ```
118
+
119
+ Wake the desk from cron (the driver is the **only** thing that spawns sessions):
120
+
121
+ ```cron
122
+ # cron has its own environment — set both vars on the line (or in the crontab header)
123
+ * * * * * DESKD_CONFIG_MODULE=myapp.desk DESKD_WAKE_EXECUTE=1 /path/to/deskd/scripts/cron/wake_orchestrator.sh
124
+ ```
125
+
126
+ It is **dry-run by default** — schedule it, watch the log, then set
127
+ `DESKD_WAKE_EXECUTE=1` when the decisions look right.
128
+
129
+ ### Agents schedule themselves — declaratively
130
+
131
+ ```bash
132
+ # a calendar wake (weekday 06:15, in your configured tz)
133
+ deskd hook add --for operator --title "daily digest" --cron "15 6 * * *"
134
+
135
+ # your own watcher algorithm: return a dict -> it wakes you
136
+ deskd hook add --for operator --title "queue depth watch" \
137
+ --probe myapp.watchers:queue_depth --every 600
138
+ ```
139
+
140
+ ```python
141
+ # myapp/watchers.py — a probe may observe and notify. Nothing else.
142
+ def queue_depth():
143
+ n = measure()
144
+ if n > 100:
145
+ return {"title": f"queue at {n}", "priority": "urgent"}
146
+ return None # None = don't wake anyone
147
+ ```
148
+
149
+ Three consecutive probe errors auto-disable the hook and notify its owner — a
150
+ broken watcher can't rot silently or stall the tick.
151
+
152
+ ## Design notes
153
+
154
+ **Headless sessions can't be interrupted mid-turn.** So "deliver to the agent"
155
+ means two things: while it's running, its `PostToolUse` hook surfaces the queue
156
+ into context; while it's idle, the orchestrator resumes its session with the
157
+ queued items as the prompt. "Current session" = a *resumable session id*, not a
158
+ live process.
159
+
160
+ **Storage is SQLite (WAL) and it is the only source of truth.** No broker, no
161
+ daemon holding state. Every tick rebuilds its decisions from the DB, so a
162
+ crashed orchestrator self-heals on the next tick. SQLite can't wake a dormant
163
+ process — the engine doesn't pretend otherwise; it makes every wake attempt an
164
+ auditable row with a closed loop and an escalation path.
165
+
166
+ **Nothing here executes your domain.** The engine wakes agents and delivers
167
+ notifications. It never acts *as* an agent, and it has no path to your
168
+ side-effecting systems.
169
+
170
+ ## Security
171
+
172
+ - The supervisor is **not** an agent role: agent APIs reject it, and supervisor
173
+ actions only enter through the authenticated web adapter.
174
+ - `simple` mode = an access code (convenience, trusted host). `signed` mode =
175
+ short-lived Ed25519 assertions from a trusted device; the public key path is
176
+ fixed at `/etc/deskd/supervisor_ed25519.pub`, must be root-owned, and is
177
+ deliberately **not** environment-overridable — an agent must not be able to
178
+ point verification at a key it wrote. Keep the private key off the host.
179
+ - **Never hardcode the access code into a client/static file.** A pre-filled
180
+ credential in page source *is* the credential. (Ask us how we know.)
181
+ - Probes only import from your explicit `probe_allowlist`. Empty = deny all.
182
+
183
+ See [`docs/security.md`](docs/security.md).
184
+
185
+ ## Docs
186
+
187
+ - [`docs/design.md`](docs/design.md) — architecture and the decisions behind it
188
+ - [`docs/roadmap.md`](docs/roadmap.md) — where this is going, in dependency order
189
+ - [`skills/agent-orchestration/`](skills/agent-orchestration/) — a skill teaching an agent to operate and evolve a deskd desk
190
+
191
+ ## License
192
+
193
+ MIT
deskd-0.1.0/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # deskd
2
+
3
+ **An orchestration engine for multi-agent desks.** deskd owns the part that is
4
+ hard and domain-agnostic: knowing which agents are alive, what they're doing,
5
+ what's queued for them, and — the difficult bit — **reliably waking the right
6
+ agent at the right time and proving the message actually landed.**
7
+
8
+ Your agents do the domain work. deskd does everything else.
9
+
10
+ > Built for headless [Claude Code](https://claude.com/claude-code) agents
11
+ > (`claude -p` + a `PostToolUse` hook), but the engine is a plain Python package
12
+ > over SQLite with no hard dependency on any particular agent runtime.
13
+
14
+ ---
15
+
16
+ ## Why
17
+
18
+ Multi-agent systems usually rot in the same three places:
19
+
20
+ 1. **Agents poll.** Every agent runs its own `sleep`/wake loop, burning tokens
21
+ to discover there's nothing to do — and still missing the thing that mattered.
22
+ 2. **Messages vanish.** "I sent it" ≠ "they read it." Nothing distinguishes
23
+ *notified* from *read*, so a stuck message is invisible until someone notices
24
+ hours later.
25
+ 3. **Nobody knows what's running.** Two sessions of the same role stomp each
26
+ other; a crashed agent looks identical to an idle one.
27
+
28
+ deskd's position: **agents must never manage their own waking.** They end their
29
+ turn and the orchestrator wakes them — on a timer, on a calendar, on a custom
30
+ watcher, on a message, or on an escalation ladder when a wake doesn't land.
31
+
32
+ ## What you get
33
+
34
+ | | |
35
+ |---|---|
36
+ | **Presence** | One live session per role, enforced by a role-scoped `flock`. Heartbeats from the in-session hook; crash-safe (the kernel releases the lock). |
37
+ | **Unified inbox** | Every notification — alerts, signals, system events, meeting messages — lands in one queue per role, with per-key dedup so a re-firing alert never piles up. |
38
+ | **Wake orchestration** | Collect demand → route by presence → record the attempt → **verify the loop closed** → escalate. A wake that doesn't land climbs: in-session hook → resume → spawn → human (Discord/email) → a red badge on the supervisor console that never times out. |
39
+ | **Self-service wake hooks** | An agent registers its own wakes: `--at` (one-shot), `--every` (interval), `--cron` (calendar, DST-correct), or `--probe` (**your own watcher function** — return a dict and it wakes you). |
40
+ | **Delivery ledger** | Per message × recipient: `queued → notified → read`. Past SLA and unread with nobody reacting = **`overdue`** — surfaced red. Rows are a projection of durable messages, so a delivery can't be silently lost. |
41
+ | **Bounded meetings** | Multi-agent meetings with check-in/quorum, mandatory 1:1 replies with an SLA, message budgets, and a mutual termination handshake. Bounded by construction — no infinite agent chatter. |
42
+ | **Cross-session tasks** | Work items that outlive a session. Soft deadlines (`due_at`) sort to the top but **never wake anyone**; only `priority=urgent` does. |
43
+ | **Session lifecycle** | Intraday continuity, cross-day rollover: wind the old session down with a handoff, start fresh the next day. |
44
+ | **Supervisor console** | A web board (live status + queue + hooks + wake activity), a per-agent detail page with full execution history, and a meetings console — behind an access-code or Ed25519 trusted-device gate. |
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install -e ".[web]" # engine + web console
50
+ ```
51
+
52
+ ## Quickstart
53
+
54
+ Describe your desk in a module that defines `configure_deskd()`:
55
+
56
+ ```python
57
+ # myapp/desk.py
58
+ from deskd.config import RoleSpec, PromptBuilder, configure
59
+
60
+ class MyPrompts(PromptBuilder):
61
+ def bootstrap(self, role: str) -> str:
62
+ return f"Load the myapp skill, declare role={role}, follow its playbook."
63
+
64
+ def configure_deskd(): # deskd calls this at startup
65
+ configure(
66
+ roles=(
67
+ RoleSpec("researcher", "Researcher", ("research", "review")),
68
+ RoleSpec("operator", "Operator", ("execution",), {"can_execute": True}),
69
+ ),
70
+ timezone="America/New_York",
71
+ inbox_sources=("alert", "signal", "system", "meeting", "supervisor"),
72
+ probe_allowlist=("myapp.watchers",), # empty = no probes may run
73
+ prompt_builder=MyPrompts(),
74
+ )
75
+ ```
76
+
77
+ Point deskd at it with **`DESKD_CONFIG_MODULE`**. Every deskd process — the CLI,
78
+ `deskd serve`, the cron driver — imports that module and calls `configure_deskd()`
79
+ before it touches the engine, so your roles are registered everywhere. Without it
80
+ a deskd process starts empty (no roles) and every role-scoped command is rejected.
81
+
82
+ ```bash
83
+ export DESKD_CONFIG_MODULE=myapp.desk # (myapp must be importable — on PYTHONPATH)
84
+
85
+ deskd serve # supervisor console on 127.0.0.1:8000
86
+ deskd status set --role operator --activity "watching the queue"
87
+ deskd inbox enqueue --for operator --source alert --title "threshold crossed" --priority urgent
88
+ deskd wake sources --role operator # what can wake me, and how to change it
89
+ ```
90
+
91
+ Wake the desk from cron (the driver is the **only** thing that spawns sessions):
92
+
93
+ ```cron
94
+ # cron has its own environment — set both vars on the line (or in the crontab header)
95
+ * * * * * DESKD_CONFIG_MODULE=myapp.desk DESKD_WAKE_EXECUTE=1 /path/to/deskd/scripts/cron/wake_orchestrator.sh
96
+ ```
97
+
98
+ It is **dry-run by default** — schedule it, watch the log, then set
99
+ `DESKD_WAKE_EXECUTE=1` when the decisions look right.
100
+
101
+ ### Agents schedule themselves — declaratively
102
+
103
+ ```bash
104
+ # a calendar wake (weekday 06:15, in your configured tz)
105
+ deskd hook add --for operator --title "daily digest" --cron "15 6 * * *"
106
+
107
+ # your own watcher algorithm: return a dict -> it wakes you
108
+ deskd hook add --for operator --title "queue depth watch" \
109
+ --probe myapp.watchers:queue_depth --every 600
110
+ ```
111
+
112
+ ```python
113
+ # myapp/watchers.py — a probe may observe and notify. Nothing else.
114
+ def queue_depth():
115
+ n = measure()
116
+ if n > 100:
117
+ return {"title": f"queue at {n}", "priority": "urgent"}
118
+ return None # None = don't wake anyone
119
+ ```
120
+
121
+ Three consecutive probe errors auto-disable the hook and notify its owner — a
122
+ broken watcher can't rot silently or stall the tick.
123
+
124
+ ## Design notes
125
+
126
+ **Headless sessions can't be interrupted mid-turn.** So "deliver to the agent"
127
+ means two things: while it's running, its `PostToolUse` hook surfaces the queue
128
+ into context; while it's idle, the orchestrator resumes its session with the
129
+ queued items as the prompt. "Current session" = a *resumable session id*, not a
130
+ live process.
131
+
132
+ **Storage is SQLite (WAL) and it is the only source of truth.** No broker, no
133
+ daemon holding state. Every tick rebuilds its decisions from the DB, so a
134
+ crashed orchestrator self-heals on the next tick. SQLite can't wake a dormant
135
+ process — the engine doesn't pretend otherwise; it makes every wake attempt an
136
+ auditable row with a closed loop and an escalation path.
137
+
138
+ **Nothing here executes your domain.** The engine wakes agents and delivers
139
+ notifications. It never acts *as* an agent, and it has no path to your
140
+ side-effecting systems.
141
+
142
+ ## Security
143
+
144
+ - The supervisor is **not** an agent role: agent APIs reject it, and supervisor
145
+ actions only enter through the authenticated web adapter.
146
+ - `simple` mode = an access code (convenience, trusted host). `signed` mode =
147
+ short-lived Ed25519 assertions from a trusted device; the public key path is
148
+ fixed at `/etc/deskd/supervisor_ed25519.pub`, must be root-owned, and is
149
+ deliberately **not** environment-overridable — an agent must not be able to
150
+ point verification at a key it wrote. Keep the private key off the host.
151
+ - **Never hardcode the access code into a client/static file.** A pre-filled
152
+ credential in page source *is* the credential. (Ask us how we know.)
153
+ - Probes only import from your explicit `probe_allowlist`. Empty = deny all.
154
+
155
+ See [`docs/security.md`](docs/security.md).
156
+
157
+ ## Docs
158
+
159
+ - [`docs/design.md`](docs/design.md) — architecture and the decisions behind it
160
+ - [`docs/roadmap.md`](docs/roadmap.md) — where this is going, in dependency order
161
+ - [`skills/agent-orchestration/`](skills/agent-orchestration/) — a skill teaching an agent to operate and evolve a deskd desk
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,173 @@
1
+ # Design
2
+
3
+ The decisions behind deskd, and why they're the way they are.
4
+
5
+ ## The constraint everything follows from
6
+
7
+ A headless agent session **runs one turn and exits**. You cannot inject a prompt
8
+ into a turn that is already running. Everything below is a consequence.
9
+
10
+ - "Deliver to the agent" = two mechanisms: the in-session hook surfaces items
11
+ into a *running* turn's context; the orchestrator **resumes the session id**
12
+ when it's idle.
13
+ - "The agent's current session" = a **resumable session id**, not a live process.
14
+ Between wakes there is no process. That is normal.
15
+ - Minimum latency to a busy agent = its current sub-task, because the hook can
16
+ only surface; the agent acts at its next natural boundary. The alternative —
17
+ interrupting mid-work — is worse.
18
+
19
+ ## Storage: SQLite (WAL), and it is the only truth
20
+
21
+ No broker, no daemon holding state in memory. Every tick rebuilds all decisions
22
+ from the database, so a crashed orchestrator self-heals on the next tick.
23
+
24
+ SQLite cannot wake a dormant process. deskd doesn't pretend otherwise: instead of
25
+ faking push, it makes every wake attempt an **auditable row** with a closed loop
26
+ and an escalation path. Honest pull with verification beats fake push.
27
+
28
+ ## Waking
29
+
30
+ **Six demand sources** feed one queue: meeting wakes, stuck deliveries, urgent
31
+ tasks, owed replies, inbox notifications, and an idle agent's own open queue
32
+ (plus the hooks an agent registers itself).
33
+
34
+ **One rule with teeth:** a task's `due_at` is a *soft* deadline. It sorts to the
35
+ top and is surfaced everywhere, but it **never wakes anyone**. Deadlines shape
36
+ attention; they don't manufacture interrupts.
37
+
38
+ That rule was written against **interruption**, and it was over-broad: it also
39
+ stopped anything waking an *idle* agent, which interrupts nothing. An idle agent
40
+ with a to-do list slept forever and the list was write-only — so "agents never
41
+ manage their own waking" was false. What waking a task earns is now:
42
+
43
+ | task state | agent state | |
44
+ |---|---|---|
45
+ | `priority=urgent` | any | **wake** — interrupting is the point |
46
+ | open | idle | **wake** — nothing is being interrupted; this is scheduling |
47
+ | open | busy | **never** — that is the interrupt the rule forbids |
48
+
49
+ A **task wake can never reach a human rung.** The ladder climbs to a person
50
+ because a message *must land*; a to-do list has no such property, so `idle_task`
51
+ is fenced to rungs that declare `leaves_machine=False`. Homework does not page
52
+ anyone at 3am.
53
+
54
+ **A task is in a live state or it is not open.** Doing it → `in_progress`.
55
+ Waiting → `blocked`, which **must name its dependency** (`blocked_on`); nothing
56
+ else counts as blocked. Someone else's → transfer it. Genuinely stuck → escalate,
57
+ and the supervisor decides whether it still matters. `pending` forever is not a
58
+ resting state: the list is a work queue, not a graveyard.
59
+
60
+ **Stall breaks the loop with a rule, not a cooldown.** A task woken for
61
+ `idle_task_stall_wakes` idle wakes without moving is *stalled* — derived at read
62
+ time from the wake ledger, never stored — and **leaves the actionable set**. It
63
+ stops causing wakes and becomes a reported fact for someone to decide about.
64
+
65
+ **The ladder** (in-session hook → resume → spawn → human → supervisor badge) is
66
+ append-only: escalating supersedes the old attempt and inserts a new one, so a
67
+ wake's full history is auditable. Each rung has an SLA; resolution closes the
68
+ attempt and records latency. The terminal rung never times out — it just stays red
69
+ on the console. Semantics are **at-least-once wake + idempotent ack**, not
70
+ exactly-once.
71
+
72
+ **Decision and execution are separate.** `plan_wakes()` is pure: it collects,
73
+ records, and returns a plan — it never spawns anything. The driver executes. This
74
+ is why the decision layer is testable and why a dry run can be genuinely
75
+ side-effect-free.
76
+
77
+ ## Delivery
78
+
79
+ A ledger row per (message × recipient): `queued → notified → read`. Past SLA and
80
+ unread splits two ways:
81
+
82
+ - **`escalated`** — something is reacting. The system is working.
83
+ - **`overdue`** — nothing is reacting. **This is the guarantee breaking**, and it
84
+ is surfaced red rather than hidden.
85
+
86
+ The ledger is a **projection** of durable messages, so it is self-healing: a
87
+ missing row is re-derived because the source message still exists. Rows are never
88
+ deleted. Time-dependent state is computed at read time, never stored, so it can't
89
+ go stale.
90
+
91
+ Two rules learned the hard way:
92
+
93
+ 1. **Never mark delivered speculatively.** Delivery is proven by the in-session
94
+ hook or an explicit ack — never by "we planned to send it". A plan whose
95
+ execution is skipped (role lock held) or fails would otherwise lose the item
96
+ *and* suppress the escalation that should have caught it.
97
+ 2. **Scope "is this handled?" to (recipient, item)**, never to the container. A
98
+ thread-level flag from one role once masked every other role's stuck message on
99
+ that thread, permanently.
100
+
101
+ ## Presence and one session per role
102
+
103
+ `agent_sessions` is keyed by **role** — reflecting the invariant that at most one
104
+ session per role exists, enforced by a role-scoped `flock` that every starter
105
+ takes. The kernel releases it on crash, so stale locks can't exist.
106
+
107
+ Liveness is derived from heartbeat age (`online`/`suspect`/`dead`/`offline`/
108
+ `never`). Heartbeats ride the in-session hook — no extra process. Registration is
109
+ explicit, so unrelated sessions never pollute the board.
110
+
111
+ The board shows "now executing" **only for a live session**. An ended session's
112
+ last activity is shown as history, never as current work.
113
+
114
+ ## Sessions: intraday continuity, cross-day restart
115
+
116
+ Within a day, resume (context is preserved, cheap). Across days, restart: wind the
117
+ old session down with a **handoff note**, then open fresh.
118
+
119
+ Long-lived sessions rot: context grows monotonically, compaction drops detail
120
+ unpredictably, and instruction changes never land. A daily restart forces durable
121
+ state — anything worth keeping must be in a task, the journal, or the knowledge
122
+ base, not in a session's memory. Continuity is **structured state, not
123
+ conversation**.
124
+
125
+ ## Hooks: agents schedule themselves
126
+
127
+ Agents must never poll. So they must be able to say "wake me later / when X":
128
+ `at`, `interval`, `cron` (calendar, DST-correct via `zoneinfo`, computed by a
129
+ minute-scan — trivial cost, no clever math to get wrong), and `probe` (a host
130
+ function; truthy return = notification = wake).
131
+
132
+ Probes are restricted to a configured allowlist, may only observe and notify, and
133
+ auto-disable after consecutive errors (with an inbox notice to the owner) so a
134
+ broken watcher can neither rot silently nor stall the tick.
135
+
136
+ **Anything that can block does not belong in a probe.** Detection that hits the
137
+ network runs in its own process and enqueues to the inbox; the orchestrator
138
+ delivers. Detection and delivery stay decoupled — otherwise one slow fetch stalls
139
+ all waking.
140
+
141
+ ## Meetings
142
+
143
+ Bounded by construction: idle deadline, message budget, automatic consensus at a
144
+ threshold, one position each, and a mutual propose/confirm termination handshake.
145
+ The transport rejects duplicates, and rejects stacked unresolved questions for
146
+ callers that ask for a reply — meetings deliberately do not, tracking each as
147
+ its own obligation instead, so one answer may settle several. Turn-taking is
148
+ NOT a bound: refusing to let a present party speak because an absent one owes
149
+ a reply drops messages rather than preventing them, since a rejected insert is
150
+ the only way a message is lost once delivery and the wake ladder are carrying
151
+ it. An owed reply is nudged, never enforced at the door. Termination
152
+ votes don't consume the budget, so a meeting can always stop. The tally counts
153
+ only *active* attendees, so someone leaving can never deadlock closure.
154
+
155
+ The protocol's core integrity rule: **never create both sides**. An agent cannot
156
+ fabricate the counterpart's attendance, reports, or votes — if the other side
157
+ never arrives, the artifact stands alone and the meeting pauses or escalates.
158
+
159
+ ## The supervisor is not an agent
160
+
161
+ A human oversight identity, outside the agent role space, reachable only through
162
+ an authenticated web adapter. Agent APIs reject it; a message claiming to be from
163
+ them carries no authority. See [security.md](security.md).
164
+
165
+ ## What deskd deliberately does not do
166
+
167
+ - **Execute your domain.** It wakes agents and delivers notifications. It never
168
+ acts *as* an agent, and has no path to your side-effecting systems.
169
+ - **Own your roles.** The registry is configuration. The engine has no idea what
170
+ your agents are for.
171
+ - **Guarantee exactly-once anything.** At-least-once + idempotent acks, with every
172
+ attempt auditable.
173
+ - **Pretend it can push.** See above.