@lightupai/polaris 0.0.11 → 0.0.13
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.
- package/README.md +1 -0
- package/docs/design-named-agents.md +303 -0
- package/docs/design-sessions.md +143 -0
- package/package.json +1 -1
- package/skills/polaris/SKILL.md +5 -5
- package/src/client/client.ts +11 -9
- package/src/daemon/daemon.ts +56 -24
- package/src/types.ts +2 -2
- package/src/web/views.ts +2 -2
- package/tests/e2e.test.ts +1 -1
- package/tests/web.test.ts +2 -2
package/README.md
CHANGED
|
@@ -117,6 +117,7 @@ tests/ Test suite (bun test)
|
|
|
117
117
|
- [ ] Postgres backup cron job — scheduled `pg_dump` to Hetzner object storage for production disaster recovery
|
|
118
118
|
- [ ] Daemon local buffer — write-ahead log for fault tolerance. If the API is slow or down, the daemon should persist events locally and flush them asynchronously with retry/backoff, so hooks and MCP tools never block or lose data
|
|
119
119
|
- [ ] Reconciliation and recovery — `polaris recover` command that diffs the daemon JSONL log against the DB, backfills missing events, and posts an abridged recovery summary to Slack as a thread reply at the correct timeline position
|
|
120
|
+
- [ ] CD pipeline for Hetzner — auto-deploy to production on merge to master (SSH + docker compose up), similar to the npm publish job
|
|
120
121
|
|
|
121
122
|
## Development
|
|
122
123
|
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# Design: Named Agents as Teammates
|
|
2
|
+
|
|
3
|
+
## Vision
|
|
4
|
+
|
|
5
|
+
Named agents are persistent, specialized participants in the Polaris workspace. They're peers alongside humans — they observe, contribute, and can be addressed by name. Unlike human sessions (transient, one project at a time), named agents are long-running services that participate across multiple projects simultaneously.
|
|
6
|
+
|
|
7
|
+
## Examples
|
|
8
|
+
|
|
9
|
+
| Agent | Identity | Specialty | Joins projects that... |
|
|
10
|
+
|-------|----------|-----------|----------------------|
|
|
11
|
+
| Dean | `agent:dean` | Data engineering, SQL, pipelines | interact with databases or data warehouses |
|
|
12
|
+
| Martha | `agent:martha` | Marketing copy, campaigns, email | need marketing content or strategy |
|
|
13
|
+
| Sean | `agent:sean` | Sales enablement, CRM, outreach | involve sales workflows or customer data |
|
|
14
|
+
| Sage | `agent:sage` | Security review, compliance | touch auth, encryption, or PII handling |
|
|
15
|
+
|
|
16
|
+
## How They Differ from Human Sessions
|
|
17
|
+
|
|
18
|
+
| Dimension | Human session | Named agent |
|
|
19
|
+
|-----------|--------------|-------------|
|
|
20
|
+
| Lifecycle | Transient — open, work, close | Persistent — always running |
|
|
21
|
+
| Projects | One at a time (join/leave) | Multiple simultaneously |
|
|
22
|
+
| Hosting | Client machine (Claude Code, Cursor) | Server-side (cloud container, sandbox) |
|
|
23
|
+
| Identity | `user:manu.bansal` | `agent:dean` |
|
|
24
|
+
| Invocation | Manual (`/polaris join`) | Auto-join on invite or project match |
|
|
25
|
+
| Driver role | Can be driver | Typically advisor, sometimes driver |
|
|
26
|
+
| Hooks | Captures via local hooks | Events posted directly via API |
|
|
27
|
+
|
|
28
|
+
## Participation Model
|
|
29
|
+
|
|
30
|
+
A named agent's relationship to a project:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
┌─────────────────┐
|
|
34
|
+
│ THE FLOOR │
|
|
35
|
+
│ │
|
|
36
|
+
│ polaris-dev │
|
|
37
|
+
│ ┌───────────┐ │
|
|
38
|
+
│ │ manu │──── driver (human, transient)
|
|
39
|
+
│ │ dean │──── advisor (agent, persistent)
|
|
40
|
+
│ │ sage │──── advisor (agent, persistent)
|
|
41
|
+
│ └───────────┘ │
|
|
42
|
+
│ │
|
|
43
|
+
│ data-pipeline │
|
|
44
|
+
│ ┌───────────┐ │
|
|
45
|
+
│ │ alice │──── driver (human, transient)
|
|
46
|
+
│ │ dean │──── advisor (agent, persistent)
|
|
47
|
+
│ └───────────┘ │
|
|
48
|
+
│ │
|
|
49
|
+
└─────────────────┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Dean appears in both projects. He monitors the event stream and responds when data-related questions arise or when addressed directly.
|
|
53
|
+
|
|
54
|
+
## Agent Behaviors
|
|
55
|
+
|
|
56
|
+
### Passive monitoring
|
|
57
|
+
Agent watches the event stream. When it sees a prompt or response related to its domain, it can inject an advisory message:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[user:manu.bansal] I need to add a Snowflake table for user events
|
|
61
|
+
[agent:dean] → fxm: The user_events schema already exists in warehouse.analytics.
|
|
62
|
+
Here's the current DDL: ...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Direct addressing
|
|
66
|
+
A human or another agent addresses the agent by name on Slack or in a session:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
@dean what tables have PII columns?
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Autonomous work
|
|
73
|
+
Agent is assigned as driver of its own session. It works independently, posting progress to the floor. Humans observe and advise.
|
|
74
|
+
|
|
75
|
+
## Identity Model
|
|
76
|
+
|
|
77
|
+
### Current
|
|
78
|
+
```
|
|
79
|
+
ParticipantId = /^(user|agent):[a-z0-9][a-z0-9._-]*$/
|
|
80
|
+
```
|
|
81
|
+
This already supports `agent:dean`. No change needed to the type system.
|
|
82
|
+
|
|
83
|
+
### Agent registry (new)
|
|
84
|
+
A table or config that defines named agents:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
agents:
|
|
88
|
+
- id: agent:dean
|
|
89
|
+
name: Dean
|
|
90
|
+
display_name: "Dean (Data)"
|
|
91
|
+
icon: 📊
|
|
92
|
+
description: "Data engineering specialist"
|
|
93
|
+
skills: [sql, snowflake, dbt, airflow]
|
|
94
|
+
auto_join: [projects with tag "data"]
|
|
95
|
+
hosting: server # or "sandbox"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This is metadata — it tells the system who Dean is, what icon to show on Slack, and which projects he should auto-join.
|
|
99
|
+
|
|
100
|
+
### Slack personas
|
|
101
|
+
Named agents already work with our persona system:
|
|
102
|
+
- `agent:dean` → username: "Dean (Data)", icon: 📊
|
|
103
|
+
- `agent:martha` → username: "Martha (Marketing)", icon: 📣
|
|
104
|
+
|
|
105
|
+
The `displayName()` function in `format.ts` handles this. Would need a lookup from the agent registry instead of the current string parsing.
|
|
106
|
+
|
|
107
|
+
## Identity Model
|
|
108
|
+
|
|
109
|
+
Every interaction on the floor has two distinct identities: the human and the agent. These are never conflated.
|
|
110
|
+
|
|
111
|
+
### Identity types
|
|
112
|
+
|
|
113
|
+
| Prefix | Meaning | Examples |
|
|
114
|
+
|--------|---------|---------|
|
|
115
|
+
| `user:*` | A human | `user:manu.bansal`, `user:alice.chen` |
|
|
116
|
+
| `agent:*` | An AI agent | `agent:claude`, `agent:dean`, `agent:cursor` |
|
|
117
|
+
| `slack:*` | A Slack user (advisor) | `slack:krishna` |
|
|
118
|
+
|
|
119
|
+
### Who sends what
|
|
120
|
+
|
|
121
|
+
In a coding session, the human and agent alternate. The `sender` field must reflect who actually produced the content:
|
|
122
|
+
|
|
123
|
+
| Hook event | Sender | Why |
|
|
124
|
+
|-----------|--------|-----|
|
|
125
|
+
| `UserPromptSubmit` | `user:manu.bansal` | The human typed the prompt |
|
|
126
|
+
| `Stop` | `agent:claude` | The agent produced the response |
|
|
127
|
+
| `PreToolUse` | `agent:claude` | The agent decided to use a tool |
|
|
128
|
+
| `PostToolUse` | `agent:claude` | The agent received the tool result |
|
|
129
|
+
| `inject` | `slack:krishna` or `user:alice.chen` | An advisor sent a message |
|
|
130
|
+
|
|
131
|
+
### Agent identity in a session
|
|
132
|
+
|
|
133
|
+
When a human connects a session, two identities are established:
|
|
134
|
+
- **Driver** (human): `user:manu.bansal` — from the participant ID in credentials
|
|
135
|
+
- **Agent**: `agent:claude` — the coding tool's agent identity
|
|
136
|
+
|
|
137
|
+
The agent identity could be:
|
|
138
|
+
- Generic: `agent:claude`, `agent:cursor`, `agent:copilot` (identifies the tool)
|
|
139
|
+
- Named: `agent:dean` (a named specialist agent)
|
|
140
|
+
- Session-specific: derived automatically from the tool being used
|
|
141
|
+
|
|
142
|
+
For local coding sessions, the agent identity defaults to the tool name (Claude Code → `agent:claude`). For named agents like Dean, it's explicitly `agent:dean`.
|
|
143
|
+
|
|
144
|
+
### On Slack
|
|
145
|
+
|
|
146
|
+
The Slack formatter uses the sender identity to pick the persona:
|
|
147
|
+
- `user:manu.bansal` → "Manu Bansal (session-name)" with 👤
|
|
148
|
+
- `agent:claude` → "Claude (session-name)" with 🤖
|
|
149
|
+
- `agent:dean` → "Dean (Data)" with 📊 (from agent registry)
|
|
150
|
+
- `slack:krishna` → "Krishna" with 💬
|
|
151
|
+
|
|
152
|
+
This makes the conversation on Slack clearly distinguish human prompts from agent responses.
|
|
153
|
+
|
|
154
|
+
## Architecture: Daemon as Universal Nexthop
|
|
155
|
+
|
|
156
|
+
All participants — human sessions and cloud agents alike — connect through a daemon. The daemon is the universal transit layer.
|
|
157
|
+
|
|
158
|
+
### Why always a daemon
|
|
159
|
+
|
|
160
|
+
1. **Fault tolerance**: If the API is slow or down, the daemon buffers events locally. No data loss.
|
|
161
|
+
2. **Auth**: Daemon handles token management. Agents and tools don't need to deal with auth.
|
|
162
|
+
3. **Cutover**: Switching from dev to prod is a daemon restart. Nothing else changes.
|
|
163
|
+
4. **Identity**: Daemon knows both the human and agent identity for a session. It stamps the correct sender on each event.
|
|
164
|
+
5. **Logging**: Every event passes through the daemon JSONL log for recovery.
|
|
165
|
+
|
|
166
|
+
### Local agent (human coding session)
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
┌──────────────┐ ┌──────────┐ ┌─────────────┐
|
|
170
|
+
│ Claude Code │────▶│ Daemon │────▶│ Polaris API │
|
|
171
|
+
│ │ │ :4322 │ │ │
|
|
172
|
+
│ hooks fire │ │ │ │ │
|
|
173
|
+
│ MCP tools │ │ buffers │ │ │
|
|
174
|
+
│ │ │ auth │ │ │
|
|
175
|
+
│ on laptop │ │ logging │ │ on Hetzner │
|
|
176
|
+
└──────────────┘ └──────────┘ └─────────────┘
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Cloud agent (named agent like Dean)
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
┌──────────────┐ ┌──────────┐ ┌─────────────┐
|
|
183
|
+
│ Dean process │────▶│ Daemon │────▶│ Polaris API │
|
|
184
|
+
│ │ │ (sidecar)│ │ │
|
|
185
|
+
│ listens for │ │ │ │ │
|
|
186
|
+
│ events, │ │ buffers │ │ │
|
|
187
|
+
│ responds │ │ auth │ │ │
|
|
188
|
+
│ │ │ logging │ │ │
|
|
189
|
+
│ on server │ └──────────┘ │ on Hetzner │
|
|
190
|
+
└──────────────┘ └─────────────┘
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Same architecture. The daemon runs as a sidecar container for cloud agents. The agent process talks to localhost, same as a human's Claude Code talks to localhost.
|
|
194
|
+
|
|
195
|
+
### Comparison
|
|
196
|
+
|
|
197
|
+
| Dimension | Local agent | Cloud agent |
|
|
198
|
+
|-----------|------------|-------------|
|
|
199
|
+
| Agent process | Claude Code / Cursor | Custom process or Claude API |
|
|
200
|
+
| Daemon | Runs on laptop | Sidecar container |
|
|
201
|
+
| Hook capture | Shell hooks (capture.sh) | Agent posts events directly to daemon |
|
|
202
|
+
| Human in the loop | Yes (the user) | Optional (can run autonomous) |
|
|
203
|
+
| Identity | `user:*` + `agent:claude` | `agent:dean` (no human) |
|
|
204
|
+
| Lifecycle | Transient | Persistent |
|
|
205
|
+
| Session creation | `/polaris join` | Auto-join or API call |
|
|
206
|
+
|
|
207
|
+
From the floor's perspective: identical. Events flow in, show up on Slack, appear on the dashboard. The source doesn't matter.
|
|
208
|
+
|
|
209
|
+
## Hosting Options
|
|
210
|
+
|
|
211
|
+
Given the daemon-always architecture, hosting becomes about where the daemon + agent pair runs:
|
|
212
|
+
|
|
213
|
+
### Option A: Self-hosted (any machine)
|
|
214
|
+
Agent + daemon run on any server the customer controls. Agent connects to daemon on localhost, daemon connects to Polaris API.
|
|
215
|
+
|
|
216
|
+
### Option B: Polaris-hosted
|
|
217
|
+
Polaris spawns a container pair (agent + daemon sidecar) in its own infrastructure. Admin configures the agent in the dashboard.
|
|
218
|
+
|
|
219
|
+
### Recommendation
|
|
220
|
+
Start with **Option A** — self-hosted. The customer runs the agent wherever they want. Polaris doesn't need to manage compute. Option B comes later when customers want managed agents.
|
|
221
|
+
|
|
222
|
+
## What to Implement Now
|
|
223
|
+
|
|
224
|
+
### 1. Agent registry table (small, foundational)
|
|
225
|
+
```sql
|
|
226
|
+
CREATE TABLE agents (
|
|
227
|
+
id TEXT PRIMARY KEY, -- e.g., "agent:dean"
|
|
228
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
229
|
+
name TEXT NOT NULL, -- "Dean"
|
|
230
|
+
display_name TEXT, -- "Dean (Data)"
|
|
231
|
+
icon TEXT, -- emoji or URL
|
|
232
|
+
description TEXT,
|
|
233
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
234
|
+
);
|
|
235
|
+
```
|
|
236
|
+
Just metadata. No behavior yet. Used by the Slack formatter for richer personas.
|
|
237
|
+
|
|
238
|
+
### 2. Update Slack formatting to use agent registry
|
|
239
|
+
Instead of deriving "Agent: Dean" from string parsing, look up the agent's display name and icon from the registry.
|
|
240
|
+
|
|
241
|
+
### 3. Agent identity in the dashboard
|
|
242
|
+
Show registered agents alongside users in the profile/team section.
|
|
243
|
+
|
|
244
|
+
### Defer for later
|
|
245
|
+
- Auto-join logic (which projects an agent participates in)
|
|
246
|
+
- Agent hosting (Option B WebSocket client)
|
|
247
|
+
- Agent spawning and lifecycle management
|
|
248
|
+
- Skills/capability matching
|
|
249
|
+
- Autonomous driver mode
|
|
250
|
+
|
|
251
|
+
## Context Model
|
|
252
|
+
|
|
253
|
+
### Phase 1: Shared context, no isolation (ship first)
|
|
254
|
+
One Dean instance per org. Dean participates in all projects and accumulates context across them. No boundaries between projects.
|
|
255
|
+
|
|
256
|
+
This matches the small-team reality: one data expert who knows everything about the company's data. Everyone has the same access level. Dean's cross-project knowledge is a feature, not a bug — "Dean, you set up the schema for Project A, can we reuse it in Project B?"
|
|
257
|
+
|
|
258
|
+
### Phase 2: Per-project isolation (add when needed)
|
|
259
|
+
As the team grows and projects have different confidentiality levels, Dean gets isolated per-project instances. Each instance only sees its own project's context.
|
|
260
|
+
|
|
261
|
+
Dean's identity stays the same on Slack ("Dean (Data)"), but behind the name, each project gets a fresh instance with no cross-project memory.
|
|
262
|
+
|
|
263
|
+
### Phase 3: Controlled sharing (enterprise)
|
|
264
|
+
Admin-curated shared knowledge base (schemas, conventions, docs) that all Dean instances can read. Per-project context stays isolated. Sharing policies control what crosses boundaries.
|
|
265
|
+
|
|
266
|
+
### Phase 4: Agent hierarchy (northstar)
|
|
267
|
+
Dean becomes the head of a data team. He delegates to specialized subagents:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
agent:dean (Data Lead)
|
|
271
|
+
├── agent:dean.snowflake — Snowflake schema, queries, optimization
|
|
272
|
+
├── agent:dean.spark — Spark jobs, pipeline tuning
|
|
273
|
+
├── agent:dean.dbt — dbt models, lineage, testing
|
|
274
|
+
└── agent:dean.quality — Data quality checks, anomaly detection
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Dean is the public face — humans address "Dean" and he routes to the right subagent. Subagents have:
|
|
278
|
+
- **Limited context**: only their specialty area, not the full org
|
|
279
|
+
- **Project isolation**: each subagent instance is scoped to one project
|
|
280
|
+
- **Dean as arbiter**: Dean sees across all subagents and synthesizes answers that span specialties
|
|
281
|
+
|
|
282
|
+
On the floor and Slack, subagent messages appear as "Dean (Data)" — the hierarchy is an implementation detail. Humans don't need to know which subagent answered.
|
|
283
|
+
|
|
284
|
+
When to introduce hierarchy:
|
|
285
|
+
- Team has 50+ projects and one Dean can't keep up
|
|
286
|
+
- Different projects need different levels of data expertise
|
|
287
|
+
- Compliance requires that certain subagents don't see certain data
|
|
288
|
+
- Response latency matters — subagents work in parallel
|
|
289
|
+
|
|
290
|
+
### Design principle
|
|
291
|
+
Start with the simplest model that matches how small teams actually work. Add isolation as a response to real customer needs, not speculatively. The architecture supports all phases — the difference is what context gets loaded when Dean starts participating in a project.
|
|
292
|
+
|
|
293
|
+
## Open Questions
|
|
294
|
+
|
|
295
|
+
1. **Multi-project sessions**: Dean is in all projects simultaneously. One session per project (`dean-polaris-dev`, `dean-data-pipeline`) keeps the model simple and isolation-ready for Phase 2.
|
|
296
|
+
|
|
297
|
+
2. **Agent-to-agent communication**: Can Dean ask Sage a question? The floor already supports this — any participant can inject into any session. But should there be a direct channel?
|
|
298
|
+
|
|
299
|
+
3. **Rate limiting**: A busy agent in 10 projects could flood the floor. Should agents have throttling or priority levels?
|
|
300
|
+
|
|
301
|
+
4. **Configuration UI**: Where do admins define named agents? Dashboard page? Config file? API?
|
|
302
|
+
|
|
303
|
+
5. **Credentials/secrets**: Dean needs database credentials. Sage needs access to security tools. How are agent-specific secrets managed?
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Design: Sessions
|
|
2
|
+
|
|
3
|
+
## What is a Session
|
|
4
|
+
|
|
5
|
+
A session is a single continuous conversation thread between a human and/or an agent. It represents a unit of work with continuity of context — the back-and-forth of prompts, responses, tool calls, and advisor messages that together tell the story of a task being worked on.
|
|
6
|
+
|
|
7
|
+
A session is **not** a person, a device, or a feature. People come and go (handoff). Devices reconnect. Features span multiple sessions. The session is the conversation itself.
|
|
8
|
+
|
|
9
|
+
## Session Identity
|
|
10
|
+
|
|
11
|
+
### Name
|
|
12
|
+
A short random slug: `s-7a3f`, `s-b2e1`, `s-c9d0`.
|
|
13
|
+
|
|
14
|
+
- 4 hex characters (65K possibilities per project)
|
|
15
|
+
- Generated by the daemon on join
|
|
16
|
+
- No human identity, no timestamp, no semantics
|
|
17
|
+
- Collision on create → regenerate and retry (max 3 attempts)
|
|
18
|
+
- Users can still pass an explicit name for scripts/tests
|
|
19
|
+
|
|
20
|
+
### Why not human-readable names?
|
|
21
|
+
Earlier designs used `manu-260610a` or user-chosen names like `fxm`. Problems:
|
|
22
|
+
- User-chosen names collide (two people pick `fxm`)
|
|
23
|
+
- Identity-based names break on driver handoff
|
|
24
|
+
- Date-based names imply lifecycle boundaries that don't exist
|
|
25
|
+
- Named sessions create an obligation to manage them (rename, archive, clean up)
|
|
26
|
+
|
|
27
|
+
Random slugs are disposable. Nobody manages them. They're just routing handles.
|
|
28
|
+
|
|
29
|
+
## Participants
|
|
30
|
+
|
|
31
|
+
A session has participants, not owners. At any point:
|
|
32
|
+
|
|
33
|
+
| Role | Who | Count |
|
|
34
|
+
|------|-----|-------|
|
|
35
|
+
| Driver | The human or agent actively working in the session | Exactly one (or none if open) |
|
|
36
|
+
| Agent | The AI tool responding to the driver | One per session |
|
|
37
|
+
| Advisors | Observers who can inject messages | Zero or more |
|
|
38
|
+
|
|
39
|
+
### Driver
|
|
40
|
+
The driver is the entity currently "at the keyboard." For a local coding session, the human starts as driver. For an autonomous agent session, the agent is the driver.
|
|
41
|
+
|
|
42
|
+
Drivers can hand off: `POST /projects/:proj/sessions/:sess/handoff` clears the driver, `POST /projects/:proj/sessions/:sess/driver` claims it. The session continues — same thread, new driver.
|
|
43
|
+
|
|
44
|
+
### Agent identity
|
|
45
|
+
Every session has an associated agent identity, even if the agent hasn't responded yet. For local coding sessions, this defaults to the tool name (`agent:claude`, `agent:cursor`). For named agents, it's explicit (`agent:dean`).
|
|
46
|
+
|
|
47
|
+
The agent identity is set on session creation and stored alongside the driver.
|
|
48
|
+
|
|
49
|
+
### Advisors
|
|
50
|
+
Anyone who injects a message into a session is an advisor. Advisors are not registered — they're identified by their sender identity on the inject event (`slack:krishna`, `user:alice.chen`).
|
|
51
|
+
|
|
52
|
+
## Lifecycle
|
|
53
|
+
|
|
54
|
+
### Creation
|
|
55
|
+
A session is created when someone runs `/polaris join <project>`. The daemon:
|
|
56
|
+
1. Generates a random slug
|
|
57
|
+
2. Calls the API to create the session with the driver and agent identity
|
|
58
|
+
3. Returns the session name to the user
|
|
59
|
+
|
|
60
|
+
### Active use
|
|
61
|
+
Events flow into the session: prompts, responses, tool calls, advisor injections. The daemon stamps the correct sender identity on each event (human or agent).
|
|
62
|
+
|
|
63
|
+
### Idle
|
|
64
|
+
A session with no events for some period is idle. No special handling — it just sits there. The conversation can resume at any time.
|
|
65
|
+
|
|
66
|
+
### Handoff
|
|
67
|
+
The driver releases the session. Another human or agent claims it. The conversation thread continues with a new driver. This is useful for:
|
|
68
|
+
- Shift changes ("I'm done for the day, Alice takes over")
|
|
69
|
+
- Escalation ("This needs a senior engineer")
|
|
70
|
+
- Agent takeover ("Let the agent finish this autonomously")
|
|
71
|
+
|
|
72
|
+
### Closure
|
|
73
|
+
There is no explicit close. Sessions are never deleted. They age out of relevance naturally. The dashboard could eventually hide sessions with no recent activity, but the data persists.
|
|
74
|
+
|
|
75
|
+
### Reconnection
|
|
76
|
+
If a daemon restarts or a user's machine disconnects, they can rejoin the same session by name (if they know it) or start a new one. Starting a new session is preferred — it's a clean conversation thread.
|
|
77
|
+
|
|
78
|
+
## Relationship to Projects
|
|
79
|
+
|
|
80
|
+
A session belongs to exactly one project. A project has many sessions. Sessions within the same project share the floor — their events are all visible to each other and to Slack.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Project: polaris-dev
|
|
84
|
+
├── Session s-7a3f (driver: user:manu.bansal, agent: agent:claude)
|
|
85
|
+
├── Session s-b2e1 (driver: user:alice.chen, agent: agent:claude)
|
|
86
|
+
└── Session s-c9d0 (driver: agent:dean, agent: agent:dean)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## What Shows on Slack
|
|
90
|
+
|
|
91
|
+
The Slack channel is per project. Events from all sessions in the project appear in the same channel. The session name appears in the poster's display name:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Manu Bansal (s-7a3f): Can we add a retry mechanism to the ETL pipeline?
|
|
95
|
+
Claude (s-7a3f): I'll add exponential backoff to the S3 upload step...
|
|
96
|
+
Alice Chen (s-b2e1): I'm working on the monitoring dashboard for this pipeline
|
|
97
|
+
Dean (Data) (s-c9d0): The pipeline's target table has a NOT NULL constraint on...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The session slug disambiguates when multiple sessions are active.
|
|
101
|
+
|
|
102
|
+
## What Shows on the Dashboard
|
|
103
|
+
|
|
104
|
+
The project card lists sessions with:
|
|
105
|
+
- Session name (slug)
|
|
106
|
+
- Current driver
|
|
107
|
+
- Agent identity
|
|
108
|
+
- Prompt count
|
|
109
|
+
- Last activity timestamp
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
polaris-dev #polaris-dev
|
|
113
|
+
├── s-7a3f Manu Bansal Claude 34 prompts 2 min ago
|
|
114
|
+
├── s-b2e1 Alice Chen Claude 12 prompts 15 min ago
|
|
115
|
+
└── s-c9d0 Dean (Data) Dean 8 prompts 1 min ago
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Schema
|
|
119
|
+
|
|
120
|
+
Current sessions table (no changes needed for naming):
|
|
121
|
+
```sql
|
|
122
|
+
CREATE TABLE sessions (
|
|
123
|
+
name TEXT NOT NULL,
|
|
124
|
+
project_id UUID NOT NULL REFERENCES projects(id),
|
|
125
|
+
org_id TEXT NOT NULL,
|
|
126
|
+
driver TEXT, -- participant ID of current driver (nullable)
|
|
127
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
128
|
+
PRIMARY KEY (project_id, name)
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Future addition for agent identity:
|
|
133
|
+
```sql
|
|
134
|
+
ALTER TABLE sessions ADD COLUMN agent TEXT; -- e.g., "agent:claude", "agent:dean"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Open Questions
|
|
138
|
+
|
|
139
|
+
1. **Session display name**: Should users be able to attach a label to a session after creation? e.g., `s-7a3f → "ETL retry fix"`. Optional metadata, not the primary key.
|
|
140
|
+
|
|
141
|
+
2. **Session limit**: Should there be a max number of active sessions per project? Probably not — let it grow naturally.
|
|
142
|
+
|
|
143
|
+
3. **Session search**: As sessions accumulate, how do you find a past conversation? By date? By participant? By content? This is a future search/filter feature.
|
package/package.json
CHANGED
package/skills/polaris/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: polaris
|
|
3
3
|
description: Connect to a Polaris multiplayer collaboration session
|
|
4
4
|
allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename
|
|
5
|
-
argument-hint: [join <project>
|
|
5
|
+
argument-hint: [join <project> | rename <new-name> | disconnect | (no args for status)]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Polaris — Multiplayer Collaboration
|
|
@@ -13,10 +13,10 @@ Manage your connection to a Polaris collaboration session.
|
|
|
13
13
|
|
|
14
14
|
Based on the arguments provided, do ONE of the following:
|
|
15
15
|
|
|
16
|
-
**`/polaris join <project
|
|
17
|
-
1. Call `polaris_connect` with the given project
|
|
18
|
-
2.
|
|
19
|
-
3. Report the connection status
|
|
16
|
+
**`/polaris join <project>`** — Connect to a session:
|
|
17
|
+
1. Call `polaris_connect` with the given project and user identity `user:manu.bansal`
|
|
18
|
+
2. A session name is auto-generated (e.g., `s-7a3f`)
|
|
19
|
+
3. Report the connection status including the session name
|
|
20
20
|
|
|
21
21
|
**`/polaris rename <new-name>`** — Rename the current project:
|
|
22
22
|
1. Call `polaris_rename` with the new name
|
package/src/client/client.ts
CHANGED
|
@@ -54,10 +54,11 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
54
54
|
type: "object" as const,
|
|
55
55
|
properties: {
|
|
56
56
|
project: { type: "string", description: "Project name" },
|
|
57
|
-
session: { type: "string", description: "Session name" },
|
|
58
57
|
user: { type: "string", description: "Your participant ID (e.g., user:manu)" },
|
|
58
|
+
session: { type: "string", description: "Session name (optional — auto-generated if omitted)" },
|
|
59
|
+
agent: { type: "string", description: "Agent identity (optional — defaults to agent:claude)" },
|
|
59
60
|
},
|
|
60
|
-
required: ["project", "
|
|
61
|
+
required: ["project", "user"],
|
|
61
62
|
},
|
|
62
63
|
},
|
|
63
64
|
{
|
|
@@ -116,22 +117,23 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
116
117
|
const { name, arguments: args } = req.params;
|
|
117
118
|
|
|
118
119
|
if (name === "polaris_connect") {
|
|
119
|
-
const { project, session,
|
|
120
|
+
const { project, user, session, agent } = args as { project: string; user: string; session?: string; agent?: string };
|
|
120
121
|
try {
|
|
121
122
|
const res = await daemonPost("/connect", {
|
|
122
123
|
ccSessionId: CC_SESSION_ID,
|
|
123
124
|
project,
|
|
124
|
-
session,
|
|
125
125
|
user,
|
|
126
|
+
...(session ? { session } : {}),
|
|
127
|
+
...(agent ? { agent } : {}),
|
|
126
128
|
});
|
|
127
|
-
const body = await res.json();
|
|
129
|
+
const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string };
|
|
128
130
|
if (res.ok) {
|
|
129
|
-
currentProject = project;
|
|
130
|
-
currentSession = session;
|
|
131
|
+
currentProject = body.project ?? project;
|
|
132
|
+
currentSession = body.session ?? session ?? "";
|
|
131
133
|
currentUser = user;
|
|
132
|
-
return { content: [{ type: "text", text: `Connected to ${
|
|
134
|
+
return { content: [{ type: "text", text: `Connected to ${currentProject}/${currentSession} as ${user}.` }] };
|
|
133
135
|
}
|
|
134
|
-
return { content: [{ type: "text", text: `Failed to connect: ${
|
|
136
|
+
return { content: [{ type: "text", text: `Failed to connect: ${body.error ?? "unknown error"}` }] };
|
|
135
137
|
} catch {
|
|
136
138
|
return { content: [{ type: "text", text: "Failed to connect — is the Polaris daemon running? Start it with `polaris daemon` or `bun run src/daemon/daemon.ts`." }] };
|
|
137
139
|
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -10,10 +10,15 @@ interface SessionMapping {
|
|
|
10
10
|
project: string;
|
|
11
11
|
session: string;
|
|
12
12
|
user: string;
|
|
13
|
+
agent: string;
|
|
13
14
|
slackChannel?: string;
|
|
14
15
|
ws: WebSocket | null;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function generateSessionName(): string {
|
|
19
|
+
return `s-${crypto.randomUUID().slice(0, 4)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
const sessions = new Map<string, SessionMapping>(); // keyed by ccSessionId
|
|
18
23
|
|
|
19
24
|
// IPC callbacks for MCP servers to receive advisor messages
|
|
@@ -188,6 +193,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
188
193
|
project: "",
|
|
189
194
|
session: "",
|
|
190
195
|
user: "",
|
|
196
|
+
agent: "",
|
|
191
197
|
ws: null,
|
|
192
198
|
});
|
|
193
199
|
}
|
|
@@ -203,22 +209,28 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
203
209
|
const body = (await req.json()) as {
|
|
204
210
|
ccSessionId: string;
|
|
205
211
|
project: string;
|
|
206
|
-
session
|
|
212
|
+
session?: string;
|
|
207
213
|
user: string;
|
|
214
|
+
agent?: string;
|
|
208
215
|
};
|
|
209
216
|
await logEvent("/connect", body);
|
|
210
|
-
if (!body.ccSessionId || !body.project || !body.
|
|
211
|
-
return error("ccSessionId, project,
|
|
217
|
+
if (!body.ccSessionId || !body.project || !body.user) {
|
|
218
|
+
return error("ccSessionId, project, and user are required", 400);
|
|
212
219
|
}
|
|
213
220
|
|
|
221
|
+
// Generate session name if not provided
|
|
222
|
+
const sessionName = body.session || generateSessionName();
|
|
223
|
+
const agentId = body.agent || "agent:claude";
|
|
224
|
+
|
|
214
225
|
// Disconnect existing cloud WS if switching sessions
|
|
215
226
|
disconnectCloudWs(body.ccSessionId);
|
|
216
227
|
|
|
217
228
|
const mapping: SessionMapping = {
|
|
218
229
|
ccSessionId: body.ccSessionId,
|
|
219
230
|
project: body.project,
|
|
220
|
-
session:
|
|
231
|
+
session: sessionName,
|
|
221
232
|
user: body.user,
|
|
233
|
+
agent: agentId,
|
|
222
234
|
ws: null,
|
|
223
235
|
};
|
|
224
236
|
sessions.set(body.ccSessionId, mapping);
|
|
@@ -232,24 +244,39 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
232
244
|
}); // Ignore 409 (already exists)
|
|
233
245
|
|
|
234
246
|
// Ensure the session exists (create if not, claim driver)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (!sessionRes.ok && sessionRes.status !== 409) {
|
|
241
|
-
const err = await sessionRes.text();
|
|
242
|
-
await logEvent("/connect", body, { status: sessionRes.status, body: err });
|
|
243
|
-
return error(`Failed to create session: ${err}`, 500);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// If session already existed, try to claim driver
|
|
247
|
-
if (sessionRes.status === 409) {
|
|
248
|
-
await fetch(`${serviceUrl}/projects/${body.project}/sessions/${body.session}/driver`, {
|
|
247
|
+
// Retry with new name on 409 (collision with generated name)
|
|
248
|
+
let attempts = 0;
|
|
249
|
+
let created = false;
|
|
250
|
+
while (!created && attempts < 3) {
|
|
251
|
+
const sessionRes = await fetch(`${serviceUrl}/projects/${body.project}/sessions`, {
|
|
249
252
|
method: "POST",
|
|
250
253
|
headers: await authHeaders(),
|
|
251
|
-
body: JSON.stringify({ driver: body.user }),
|
|
252
|
-
});
|
|
254
|
+
body: JSON.stringify({ name: mapping.session, driver: body.user }),
|
|
255
|
+
});
|
|
256
|
+
if (sessionRes.ok) {
|
|
257
|
+
created = true;
|
|
258
|
+
} else if (sessionRes.status === 409) {
|
|
259
|
+
if (body.session) {
|
|
260
|
+
// Explicit session name — claim driver instead of retrying
|
|
261
|
+
await fetch(`${serviceUrl}/projects/${body.project}/sessions/${mapping.session}/driver`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: await authHeaders(),
|
|
264
|
+
body: JSON.stringify({ driver: body.user }),
|
|
265
|
+
});
|
|
266
|
+
created = true;
|
|
267
|
+
} else {
|
|
268
|
+
// Generated name collision — retry with new name
|
|
269
|
+
mapping.session = generateSessionName();
|
|
270
|
+
attempts++;
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
const err = await sessionRes.text();
|
|
274
|
+
await logEvent("/connect", body, { status: sessionRes.status, body: err });
|
|
275
|
+
return error(`Failed to create session: ${err}`, 500);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!created) {
|
|
279
|
+
return error("Failed to generate unique session name", 500);
|
|
253
280
|
}
|
|
254
281
|
|
|
255
282
|
// Fetch Slack channel name for status display
|
|
@@ -268,9 +295,10 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
268
295
|
|
|
269
296
|
return json({
|
|
270
297
|
status: "connected",
|
|
271
|
-
project:
|
|
272
|
-
session:
|
|
273
|
-
user:
|
|
298
|
+
project: mapping.project,
|
|
299
|
+
session: mapping.session,
|
|
300
|
+
user: mapping.user,
|
|
301
|
+
agent: mapping.agent,
|
|
274
302
|
});
|
|
275
303
|
} catch {
|
|
276
304
|
return error("Invalid JSON", 400);
|
|
@@ -333,6 +361,10 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
333
361
|
}
|
|
334
362
|
}
|
|
335
363
|
|
|
364
|
+
// Determine sender: human for prompts, agent for everything else
|
|
365
|
+
const hookEvent = body.hook_event_name as string | undefined;
|
|
366
|
+
const sender = hookEvent === "UserPromptSubmit" ? mapping.user : mapping.agent;
|
|
367
|
+
|
|
336
368
|
// Relay to cloud service
|
|
337
369
|
const serviceUrl = getServiceUrl();
|
|
338
370
|
const res = await fetch(
|
|
@@ -340,7 +372,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
340
372
|
{
|
|
341
373
|
method: "POST",
|
|
342
374
|
headers: await authHeaders(),
|
|
343
|
-
body: JSON.stringify({ sender
|
|
375
|
+
body: JSON.stringify({ sender, payload: body }),
|
|
344
376
|
}
|
|
345
377
|
);
|
|
346
378
|
|
package/src/types.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { z } from "zod";
|
|
|
5
5
|
export const ParticipantId = z
|
|
6
6
|
.string()
|
|
7
7
|
.regex(
|
|
8
|
-
/^(user|agent):[a-z0-9][a-z0-9._-]*$/,
|
|
9
|
-
"Must be user:<name
|
|
8
|
+
/^(user|agent|slack):[a-z0-9][a-z0-9._-]*$/,
|
|
9
|
+
"Must be user:<name>, agent:<name>, or slack:<name> (lowercase alphanumeric, dots, hyphens, underscores)"
|
|
10
10
|
);
|
|
11
11
|
|
|
12
12
|
export type ParticipantId = z.infer<typeof ParticipantId>;
|
package/src/web/views.ts
CHANGED
|
@@ -208,7 +208,7 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
208
208
|
<summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Join another session</summary>
|
|
209
209
|
<div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
|
|
210
210
|
<p class="text-sm text-gray-500">Inside your AI agent, run:</p>
|
|
211
|
-
${copyBlock("/polaris join <project>
|
|
211
|
+
${copyBlock("/polaris join <project>")}
|
|
212
212
|
</div>
|
|
213
213
|
</details>
|
|
214
214
|
<div class="space-y-4">
|
|
@@ -231,7 +231,7 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
231
231
|
: "Inside your AI agent (Claude Code, Cursor, etc.), run:"}</p>
|
|
232
232
|
${ctx.hasConnectedSession
|
|
233
233
|
? ""
|
|
234
|
-
: copyBlock("/polaris join my-project
|
|
234
|
+
: copyBlock("/polaris join my-project")}
|
|
235
235
|
</div>
|
|
236
236
|
</div>`);
|
|
237
237
|
}
|
package/tests/e2e.test.ts
CHANGED
|
@@ -330,7 +330,7 @@ describe("e2e: capture.sh through daemon", () => {
|
|
|
330
330
|
const body = await res.json();
|
|
331
331
|
expect(body).toHaveLength(1);
|
|
332
332
|
expect(body[0].payload.stop_response).toBe("Auth middleware is ready");
|
|
333
|
-
expect(body[0].sender).toBe("
|
|
333
|
+
expect(body[0].sender).toBe("agent:claude"); // Stop events are sent by the agent
|
|
334
334
|
});
|
|
335
335
|
|
|
336
336
|
test("hook script exits 0 when daemon is down", async () => {
|
package/tests/web.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ describe("renderSetupView", () => {
|
|
|
38
38
|
// Install CLI command is present
|
|
39
39
|
expect(html).toContain("npx @lightupai/polaris");
|
|
40
40
|
// Connect session command is present
|
|
41
|
-
expect(html).toContain("/polaris join my-project
|
|
41
|
+
expect(html).toContain("/polaris join my-project");
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test("slack done: floor shows connected, devices is highlighted, sessions grayed", () => {
|
|
@@ -66,7 +66,7 @@ describe("renderSetupView", () => {
|
|
|
66
66
|
const lastHighlight = html.lastIndexOf("border-polaris-300");
|
|
67
67
|
expect(lastHighlight).toBeGreaterThan(sessIdx);
|
|
68
68
|
// Connect session command present
|
|
69
|
-
expect(html).toContain("/polaris join my-project
|
|
69
|
+
expect(html).toContain("/polaris join my-project");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test("includes nav with user info", () => {
|