talking-stick 0.1.0-alpha

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.
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveDataDir } from "./config.js";
4
+ import { ancestorPaths, resolveContextPath } from "./path-resolution.js";
5
+ export function resolveCliSessionPath(options = {}) {
6
+ const dataDir = options.dataDir
7
+ ? path.resolve(options.dataDir)
8
+ : resolveDataDir();
9
+ return path.join(dataDir, "cli-sessions.json");
10
+ }
11
+ export function readCliSessions(sessionPath) {
12
+ try {
13
+ const raw = fs.readFileSync(sessionPath, "utf8");
14
+ const parsed = JSON.parse(raw);
15
+ return Array.isArray(parsed) ? parsed : [];
16
+ }
17
+ catch (error) {
18
+ if (error.code === "ENOENT") {
19
+ return [];
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ export function writeCliSessions(sessionPath, sessions) {
25
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
26
+ fs.writeFileSync(sessionPath, `${JSON.stringify(sessions, null, 2)}\n`);
27
+ }
28
+ export function upsertCliSession(sessionPath, session) {
29
+ const sessions = readCliSessions(sessionPath);
30
+ const index = sessions.findIndex((candidate) => candidate.agent_id === session.agent_id &&
31
+ candidate.room_id === session.room_id);
32
+ if (index === -1) {
33
+ sessions.push(session);
34
+ }
35
+ else {
36
+ sessions[index] = session;
37
+ }
38
+ writeCliSessions(sessionPath, sessions);
39
+ }
40
+ export function findCliSessionByRoom(sessionPath, agentId, roomId) {
41
+ return (readCliSessions(sessionPath).find((session) => session.agent_id === agentId && session.room_id === roomId) ?? null);
42
+ }
43
+ export function clearCliSessionLease(sessionPath, agentId, roomId) {
44
+ const session = findCliSessionByRoom(sessionPath, agentId, roomId);
45
+ if (!session) {
46
+ return;
47
+ }
48
+ upsertCliSession(sessionPath, {
49
+ ...session,
50
+ lease_id: null,
51
+ turn_id: null,
52
+ guardian_pid: null,
53
+ guardian_process_started_at: null,
54
+ updated_at: new Date().toISOString()
55
+ });
56
+ }
57
+ export function findCliSessionForContextPath(sessionPath, agentId, contextPath) {
58
+ const resolved = resolveContextPath(contextPath);
59
+ const candidates = readCliSessions(sessionPath).filter((session) => session.agent_id === agentId &&
60
+ session.workspace_root === resolved.workspace_root);
61
+ if (candidates.length === 0) {
62
+ return null;
63
+ }
64
+ const byPath = new Map();
65
+ for (const session of candidates) {
66
+ const sessionsForPath = byPath.get(session.canonical_path) ?? [];
67
+ sessionsForPath.push(session);
68
+ byPath.set(session.canonical_path, sessionsForPath);
69
+ }
70
+ for (const candidatePath of ancestorPaths(resolved.canonical_context_path, resolved.workspace_root)) {
71
+ const matches = byPath.get(candidatePath);
72
+ if (!matches || matches.length === 0) {
73
+ continue;
74
+ }
75
+ return matches
76
+ .slice()
77
+ .sort((left, right) => right.updated_at.localeCompare(left.updated_at))[0];
78
+ }
79
+ return null;
80
+ }
@@ -0,0 +1,107 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export const DEFAULT_SKILL_NAME = "talking-stick";
5
+ export function resolveBundledSkillPath(options = {}) {
6
+ return options.sourcePath ?? path.resolve(currentPackageDir(), "skills", DEFAULT_SKILL_NAME);
7
+ }
8
+ export function resolveSkillTargetPath(harness, options = {}) {
9
+ const homeDir = options.homeDir ?? process.env.HOME ?? "";
10
+ switch (harness) {
11
+ case "claude-code":
12
+ return path.join(homeDir, ".claude", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
13
+ case "codex":
14
+ return path.join(homeDir, ".codex", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
15
+ case "opencode":
16
+ return path.join(homeDir, ".opencode", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
17
+ default:
18
+ throw new Error(`Unknown skill-install harness: ${harness}`);
19
+ }
20
+ }
21
+ export function planSkillInstall(harness, options = {}) {
22
+ const skillName = options.skillName ?? DEFAULT_SKILL_NAME;
23
+ const sourcePath = resolveBundledSkillPath(options);
24
+ const shouldLink = options.link ?? true;
25
+ ensureSkillSourceExists(sourcePath);
26
+ if (harness === "gemini") {
27
+ return shouldLink
28
+ ? {
29
+ kind: "exec",
30
+ harness,
31
+ command: "gemini",
32
+ args: ["skills", "link", sourcePath, "--scope", "user", "--consent"],
33
+ description: `gemini skills link ${sourcePath} --scope user --consent`
34
+ }
35
+ : {
36
+ kind: "exec",
37
+ harness,
38
+ command: "gemini",
39
+ args: ["skills", "install", sourcePath, "--scope", "user", "--consent"],
40
+ description: `gemini skills install ${sourcePath} --scope user --consent`
41
+ };
42
+ }
43
+ const targetPath = resolveSkillTargetPath(harness, options);
44
+ return {
45
+ kind: "file-patch",
46
+ harness,
47
+ filePath: targetPath,
48
+ description: shouldLink
49
+ ? `link ${sourcePath} -> ${targetPath}`
50
+ : `copy ${sourcePath} -> ${targetPath}`,
51
+ apply: () => installSkillDirectory(sourcePath, targetPath, shouldLink)
52
+ };
53
+ }
54
+ export function planSkillUninstall(harness, options = {}) {
55
+ const skillName = options.skillName ?? DEFAULT_SKILL_NAME;
56
+ if (harness === "gemini") {
57
+ return {
58
+ kind: "exec",
59
+ harness,
60
+ command: "gemini",
61
+ args: ["skills", "uninstall", skillName, "--scope", "user"],
62
+ description: `gemini skills uninstall ${skillName} --scope user`
63
+ };
64
+ }
65
+ const targetPath = resolveSkillTargetPath(harness, options);
66
+ return {
67
+ kind: "file-patch",
68
+ harness,
69
+ filePath: targetPath,
70
+ description: `remove ${targetPath}`,
71
+ apply: () => removeInstalledSkill(targetPath)
72
+ };
73
+ }
74
+ function currentPackageDir() {
75
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
76
+ }
77
+ function ensureSkillSourceExists(sourcePath) {
78
+ const skillFile = path.join(sourcePath, "SKILL.md");
79
+ if (!fs.existsSync(skillFile)) {
80
+ throw new Error(`Talking Stick skill source not found at ${sourcePath}`);
81
+ }
82
+ }
83
+ function installSkillDirectory(sourcePath, targetPath, link) {
84
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
85
+ removeInstalledSkill(targetPath);
86
+ if (link) {
87
+ fs.symlinkSync(sourcePath, targetPath, process.platform === "win32" ? "junction" : "dir");
88
+ return;
89
+ }
90
+ fs.cpSync(sourcePath, targetPath, { recursive: true });
91
+ }
92
+ function removeInstalledSkill(targetPath) {
93
+ try {
94
+ const stat = fs.lstatSync(targetPath);
95
+ if (stat.isSymbolicLink()) {
96
+ fs.unlinkSync(targetPath);
97
+ return;
98
+ }
99
+ fs.rmSync(targetPath, { recursive: true, force: true });
100
+ }
101
+ catch (error) {
102
+ if (error.code === "ENOENT") {
103
+ return;
104
+ }
105
+ throw error;
106
+ }
107
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,191 @@
1
+ # Ambient Presence: Shell Prompt, Event Stream, and Agent Skill
2
+
3
+ **Status:** Design sketch — not yet scheduled.
4
+ **Related:** [talking-stick-plan.md](talking-stick-plan.md)
5
+
6
+ ## Purpose
7
+
8
+ Make room state *ambient* for everyone who enters a coordinated workspace — humans in their shells, and agents in their harnesses — so that awareness of the turn, the queue, and the lease does not depend on anyone remembering to query it.
9
+
10
+ The coordination primitive already exists (rooms, leases, handoffs, the state machine in `talking-stick-plan.md`). What is missing is the last-mile surface that makes it *felt*. Today, a human in a terminal has no indication that another agent is mid-turn on the repo they just `cd`'d into, and a waiting agent has no lightweight, replayable observer surface for room activity in either the harness UI or invoked shell helpers.
11
+
12
+ This document is intentionally a **layer** on top of the core protocol, not a rewrite of it. Ambient presence should project the existing room model into prompts, status queries, and followable events. It should not invent a second identity model, a second event model, or a different room-resolution rule.
13
+
14
+ ## Vision
15
+
16
+ A concrete vignette of the target UX:
17
+
18
+ 1. Human opens Claude Code in a repo with ambient presence enabled.
19
+ 2. The agent notices the ambient-presence marker or instruction and reads room status.
20
+ 3. It sees that Codex currently holds the stick and tells the human: *"Codex is holding the stick. I can watch the room and pick up when it becomes available — in the meantime, want to sketch requirements for the piece you mentioned?"*
21
+ 4. A background observer stream tails room events. If the harness can also expose its protocol identity to spawned shells, the background task may additionally act as that participant; otherwise it remains observer-only.
22
+ 5. The human and the agent design the next feature for ten minutes.
23
+ 6. The background task sees a room transition or `wait_for_turn` result indicating the stick is now available.
24
+ 7. The agent announces: *"Codex just released; I can take the turn now. Starting on the change we discussed."*
25
+
26
+ Parallel vignette for the shell: the human's `PS1` reads `~/repo 🥢 holding T42 $` when they hold the stick, `~/repo 🥢 waiting(2) $` when they are queued, and remains plain when they are outside any ambient-enabled workspace.
27
+
28
+ ## Architecture
29
+
30
+ Four independent layers. Each is useful on its own; together they become invisible.
31
+
32
+ ### 1. Discovery — "should the ambient layer engage here?"
33
+
34
+ How does anything know to engage the ambient layer at all? Options:
35
+
36
+ - **Repo marker directory** — a `.talking-stick/` dir at the workspace root, created manually for now and optionally by a future `tt init`.
37
+ - **Agent instruction file entry** — one line in `AGENTS.md` / `CLAUDE.md` / `.cursor/rules` naming the skill.
38
+ - **User-global enablement** — a setting in `~/.config/talking-stick/config.toml` that opts the user into the shell prompt integration globally.
39
+
40
+ Important distinction: the marker directory is an **ambient-presence enablement signal**, not the authoritative definition of room scope. Room identity and room lookup still follow the workspace/path resolution rules in `talking-stick-plan.md`.
41
+
42
+ ### 2. Runtime — what surfaces state
43
+
44
+ Two immediate surfaces, both driven by the local SQLite store:
45
+
46
+ - **Shell prompt fragment** — a `tt status --prompt` subcommand that prints a short PS1-safe string (or nothing). Wired into Bash `PROMPT_COMMAND`, Zsh `precmd`, Fish `fish_prompt`.
47
+ - **Background room event stream** — an extension of `tt events`, most likely `tt events --follow`, that emits one JSON line per room event to stdout and can resume from a stored `event_seq`.
48
+
49
+ The existing `tt wait` command keeps its current meaning: claimant-side wait for `your_turn` / `takeover_available`. Ambient presence should not overload `wait` into a second, room-wide event API.
50
+
51
+ A later extension may expose the same ambient state in **non-interactive invoked shells** (for example, harness command hooks or a `BASH_ENV` prelude). That is part of the broader ambient-presence story, but not part of the first shippable slice.
52
+
53
+ ### 3. Identity modes — participant or observer
54
+
55
+ Ambient presence needs two distinct operating modes:
56
+
57
+ - **Participant mode** — the runtime can reliably infer or receive the harness identity that the MCP layer would use. In this mode, a spawned shell helper may join, wait, or claim on behalf of that participant.
58
+ - **Observer mode** — the runtime cannot reliably infer the harness identity. In this mode, ambient surfaces may read room state and tail room events, but they must not join the room or represent themselves as a protocol participant.
59
+
60
+ This distinction matters because `tt` is currently the human CLI. A shell process launched from inside a harness should not silently become `human:<username>` and pollute room membership. If we can cheaply export the harness identity into child shells, great; if not, observer mode is still useful and should ship first.
61
+
62
+ That same rule applies to invoked shell helpers: if identity can be inferred or inherited, they may render participant-local state; if not, they should limit themselves to room-level observer status rather than pretending to be a participant.
63
+
64
+ ### 4. Instruction — how the agent behaves
65
+
66
+ A skill (Claude Code `Skill` format, plus equivalent bootstrap for other harnesses) teaches the agent:
67
+
68
+ - On first message in an ambient-enabled repo, determine whether it has participant-mode identity or only observer-mode visibility.
69
+ - In participant mode, use the existing coordination path (`join_path`, `wait_for_turn`, `heartbeat`, `release_stick`, `pass_stick`) as the authority for membership and ownership. A shell-side helper may mirror this, but it should not be the source of truth.
70
+ - In observer mode, read `tt status` / `tt state` and optionally tail `tt events --follow`, but do not join or claim.
71
+ - Narrate wait state naturally to the human. Do not mutate the repo while waiting; use the time for planning, requirements, review.
72
+ - If `wait_for_turn` reports `takeover_available`, surface that explicitly: the agent can offer to take over, but takeover remains a deliberate act.
73
+
74
+ ## Components
75
+
76
+ ### `tt status --prompt`
77
+
78
+ Output format (one line, no trailing newline):
79
+
80
+ | Situation | Output |
81
+ |-------------------------------------------|-----------------------------------|
82
+ | Not in an ambient-enabled workspace | *(empty)* |
83
+ | In a room, holding the stick | `🥢 holding T42` |
84
+ | In a room, queued | `🥢 waiting(2)` |
85
+ | In a room, idle (known participant) | `🥢 idle` |
86
+ | In a room, observer-only | `🥢 codex holding` |
87
+ | Lease going stale (you hold) | `🥢 holding T42 ⚠` |
88
+
89
+ `waiting(N)` needs a precise definition. Use:
90
+
91
+ - Start from the current effective head of the circular sequence:
92
+ - if the room is `owned`, the current owner is the head,
93
+ - if the room is `reserved`, the reserved recipient is the head.
94
+ - Walk forward through the circular member list.
95
+ - Count only **active** members who would receive first right of claim before the caller.
96
+ - Exclude inactive members.
97
+ - If the caller identity is unknown or the caller is not a member, do not render `waiting(N)`.
98
+
99
+ This keeps queue position stable and meaningful without pretending dormant members are still in line.
100
+
101
+ The emoji should be configurable (`TT_PROMPT_ICON`) for terminals that render it poorly. The prompt path should target sub-10 ms steady-state latency, ideally with cached room resolution and a single indexed SQLite read. Treat that as a performance goal, not as a protocol guarantee.
102
+
103
+ The prompt output stays deliberately short. A richer machine-readable `tt status --json` or `tt state --json` can carry more detail such as current owner, reservation state, queue position, and takeover eligibility.
104
+
105
+ Shell integration snippets ship under `integrations/shell/` with a `tt prompt install [bash|zsh|fish]` command that appends the right hook to the user's rc file, idempotently.
106
+
107
+ ### `tt events --follow`
108
+
109
+ Line-oriented room event stream. Stdout is JSON lines, one event per line. Stderr is for diagnostics only.
110
+
111
+ ```
112
+ tt events [path] --follow [--after <event_seq>] [--event <types>] [--json|--pretty]
113
+ ```
114
+
115
+ Flags:
116
+
117
+ - `--follow` — continue polling for new room events instead of returning a bounded page.
118
+ - `--after` — resume after the last seen `event_seq`.
119
+ - `--event` — comma-separated filter over raw room event types.
120
+ - `--json` / `--pretty` — output format.
121
+
122
+ The stream should align with the core room event log, not invent a second taxonomy. For the MVP, the on-the-wire event types should be the existing `RoomEvent` types:
123
+
124
+ | Event | Meaning |
125
+ |------------|---------------------------------------------------|
126
+ | `claim` | A member claimed the stick. |
127
+ | `release` | The current holder released with a handoff. |
128
+ | `pass` | The current holder explicitly passed the stick. |
129
+ | `takeover` | A takeover committed. |
130
+ | `close` | Reserved for the optional later `close_room`. |
131
+
132
+ Consumers can project these into higher-level phrases if they want:
133
+
134
+ - `claim` or `takeover` => "turn granted"
135
+ - `pass` => "explicit handoff offered"
136
+ - `release` => "normal sequence handoff"
137
+
138
+ But the event stream itself should stay audit-log-shaped so it matches the database and replays cleanly.
139
+
140
+ Implementation notes:
141
+
142
+ - Use the existing append-only `event_seq` for replay and resumption. Do not add a second cursor concept when `event_seq` already exists.
143
+ - The cheapest implementation is a 1-second poll on SQLite state. Latency is acceptable; coordination turns are measured in seconds to minutes, not milliseconds.
144
+ - A later optimization can use SQLite update hooks via a shared daemon, but that introduces a lifecycle we should not take on in v1.
145
+ - The process must handle `SIGTERM`/`SIGHUP` cleanly (flush stdout, exit 0). Harnesses kill tracked background tasks on session end.
146
+
147
+ ### Skill
148
+
149
+ Ships as a Claude Code skill under `integrations/skills/talking-stick/`. Parallel bootstrap files for Codex and other harnesses live under `integrations/skills/` with harness-specific naming.
150
+
151
+ The skill body covers:
152
+
153
+ - When to invoke (description matches on ambient-enabled repo context).
154
+ - The bootstrap sequence for observer mode versus participant mode.
155
+ - How to narrate wait state without being annoying.
156
+ - The non-mutation rule while waiting.
157
+ - How to react to raw room events versus `wait_for_turn` outcomes.
158
+ - How to surface `takeover_available` as a social decision instead of silently taking over.
159
+
160
+ ## Tradeoffs and open questions
161
+
162
+ - **One background process per room observer.** Idle cost is low (SQLite poll), but cleanup on session end must be reliable. Harnesses kill tracked background tasks on exit; we should verify this for each supported harness before relying on it.
163
+ - **Identity in spawned shells.** This is the real fork in the road. If a harness can cheaply export its protocol identity into child shells, participant-mode shell helpers are viable. If not, observer mode should ship first and participant mode moves to a later release.
164
+ - **Event granularity.** Coarse events (room-event log only) minimize context pollution; fine events (every lease poke, every presence blip) enable richer UX but flood. Start with raw room events plus caller-centric `tt wait`.
165
+ - **Skill activation reliability.** Skills load on description match or bootstrap, not on `cd`. The repo marker plus an `AGENTS.md` / `CLAUDE.md` line is the most reliable trigger we have without harness-specific hooks.
166
+ - **Cross-harness event format.** The event stream must be plain JSON lines — no dependency on any one harness's notification shape. Harnesses read lines; they map to their own notification system.
167
+ - **Current task in ambient status.** Showing the owner's current task would be high-signal, but it should come from the handoff that granted the current turn, not from guessed free text. That likely requires the core room projection to retain the granting handoff pointer or a current-task snapshot. Good follow-up; not a v1 requirement for the prompt fragment.
168
+ - **Non-interactive shells.** This is intentionally deferred, not dropped. `PS1` only covers interactive shells; harness command runners need a different hook. A future shell prelude or harness-specific command hook should render ambient state for invoked commands. If it can prove participant identity, it may render participant-local status; otherwise it should emit observer-only conversation status. Treat this as a follow-on stage, not as part of the first shippable surface.
169
+ - **Multiple rooms per repo.** Out of scope for v1; assume one active room per workspace path. The CLI surface should not preclude multi-room later.
170
+ - **Prompt icon portability.** Not every terminal renders `🥢` cleanly. Make it configurable; ship a plain ASCII fallback (`[TS]`).
171
+
172
+ ## Staged rollout
173
+
174
+ Each stage is independently shippable and independently useful:
175
+
176
+ 1. **`tt status --prompt` + shell integrations.** Humans get ambient awareness. No agent identity tricks needed.
177
+ 2. **`tt events --follow` on top of the existing room-event log.** Enables replayable ambient notifications and observer tooling.
178
+ 3. **Observer-mode skill for Claude Code.** Brings room awareness into the agent UX without requiring shell-side agent identity.
179
+ 4. **Participant-mode shell helpers, if identity export is practical.** If a harness can expose its protocol identity to child shells, background shell helpers may join/wait as that participant.
180
+ 5. **Non-interactive shell hooks for invoked agent commands.** Observer-only if identity export is unavailable; participant-aware if the harness can prove or inherit its protocol identity.
181
+ 6. **Optional: `tt init` and richer status projections.** Marker creation, richer task display, and shell bootstrap installers can land once the basic layer proves useful.
182
+
183
+ ## Out of scope for v1
184
+
185
+ - MCP resource subscriptions as an alternative to the shell event stream. The shell-based channel is sufficient and harness-portable; adding a second push channel is premature.
186
+ - Multi-room-per-workspace. See `talking-stick-plan.md` for the single-default-room stance.
187
+ - Treating shell observer processes as authoritative protocol participants unless they can prove or inherit the correct harness identity.
188
+
189
+ ## Why this matters
190
+
191
+ The coordination primitive without the presence surface is an appointment-only service: you have to remember to ask. With the presence surface, it becomes an ambient fact of the workspace, the way `git status` in a `PS1` made branch awareness ambient. That shift is what makes multi-agent coordination feel less like a protocol and more like a room.
@@ -0,0 +1,32 @@
1
+ # Talking Stick 0.1.0-alpha
2
+
3
+ Date: 2026-04-23
4
+
5
+ ## What this alpha includes
6
+
7
+ - TypeScript project scaffold with `tt` CLI and `tt mcp` stdio server
8
+ - SQLite persistence with migrations, WAL mode, and local-filesystem checks
9
+ - Core room protocol: `join_path`, `wait_for_turn`, `heartbeat`, `release_stick`, `pass_stick`, `takeover_stick`, `get_room_state`, `get_room_events`
10
+ - Fencing with `lease_id` + `turn_id`
11
+ - Process-aware recovery for `owner_gone`, `recipient_gone`, and dormant rooms
12
+ - Human guardian flow for long-held CLI turns
13
+ - Multi-process contention coverage and MCP identity memoization coverage
14
+
15
+ ## Notable behavior
16
+
17
+ - `join_path` returns the effective policy, including `heartbeatIntervalMs`, so holders can heartbeat on the server's cadence.
18
+ - `list_rooms` is a cheap summary view; exact liveness projection lives on the authoritative read and mutation paths.
19
+ - MCP identity is derived server-side from the spawning harness and memoized per session.
20
+
21
+ ## Known alpha boundaries
22
+
23
+ - No non-owner notes yet
24
+ - No human override/admin model yet
25
+ - No full daemon/push transport; waiting still uses bounded polling
26
+ - Ambient presence remains design work and is not part of this alpha release surface
27
+
28
+ ## Verification
29
+
30
+ - `npm run typecheck`
31
+ - `npm test`
32
+ - `npm run build`