talking-stick 0.4.8 → 0.4.10
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 +8 -6
- package/dist/cli/event-stream.js +7 -2
- package/dist/cli/grok-session-hook.js +69 -0
- package/dist/cli/guardian.js +22 -1
- package/dist/cli/install-commands.js +33 -8
- package/dist/cli/output.js +1 -1
- package/dist/cli/registry.js +10 -0
- package/dist/cli/room-commands.js +4 -2
- package/dist/cli/turn-commands.js +4 -2
- package/dist/commands.js +16 -4
- package/dist/config.js +1 -0
- package/dist/grok-session-store.js +140 -0
- package/dist/identity.js +77 -8
- package/dist/index.js +2 -1
- package/dist/install.js +104 -1
- package/dist/instructions.js +9 -1
- package/dist/path-resolution.js +31 -0
- package/dist/service.js +229 -57
- package/dist/skill-install.js +3 -1
- package/docs/releases/0.4.10.md +17 -0
- package/docs/releases/0.4.9.md +17 -0
- package/docs/talking-stick-plan.md +1 -1
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A CLI coordination tool that lets multiple AI coding agents share a single workspace without stepping on each other. One agent holds the stick at a time; handoffs carry structured context so the next agent doesn't have to re-derive it.
|
|
4
4
|
|
|
5
|
-
**Version:** 0.4.1. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box. Two agents in the same room can also chat out-of-band — without passing the stick — via `tt msg send/recv`.
|
|
5
|
+
**Version:** 0.4.1. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, Grok Build, and OpenCode out of the box. Two agents in the same room can also chat out-of-band — without passing the stick — via `tt msg send/recv`.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
@@ -105,7 +105,7 @@ tt msg send/recv — out-of-band chat into the room event log
|
|
|
105
105
|
tt instructions — editable collaboration prompt loaded by the skill
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically.
|
|
108
|
+
A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically. Marker files directly in your home directory are ignored for descendant paths, so scratch directories under `$HOME` do not collapse into one broad home-scoped room unless you explicitly join home itself.
|
|
109
109
|
|
|
110
110
|
The global skill tells the model when to join, wait, take over, leave notes, send messages, and hand off.
|
|
111
111
|
|
|
@@ -166,13 +166,14 @@ For harnesses that only notice completed subprocesses, run `tt events --wait --a
|
|
|
166
166
|
- Claude Code: copied or linked into `~/.claude/skills/talking-stick`
|
|
167
167
|
- Codex: copied or linked into `~/.codex/skills/talking-stick`
|
|
168
168
|
- Gemini: installed with `gemini skills install ... --scope user` or linked with `gemini skills link ... --scope user`
|
|
169
|
+
- Grok Build: copied or linked into `~/.grok/skills/talking-stick`, plus a trusted global session hook at `~/.grok/hooks/talking-stick-session.json`
|
|
169
170
|
- OpenCode: copied or linked into `~/.opencode/skills/talking-stick`
|
|
170
171
|
|
|
171
172
|
By default, `tt install` links the bundled skill into each harness so local updates are picked up immediately. Pass `--copy` if you want a standalone snapshot instead.
|
|
172
173
|
|
|
173
|
-
Stale MCP cleanup is strict for OpenCode JSON entries: it removes only the canonical `mcp.talking-stick` value with `["tt", "mcp"]` and leaves hand-edited entries alone. Claude Code, Codex, and Gemini cleanup uses their own `mcp remove` commands when the old server name exists. Every cleanup run appends JSONL audit entries to `${TALKING_STICK_DATA_DIR}/update-migrations.log`.
|
|
174
|
+
Stale MCP cleanup is strict for OpenCode JSON entries: it removes only the canonical `mcp.talking-stick` value with `["tt", "mcp"]` and leaves hand-edited entries alone. Claude Code, Codex, and Gemini cleanup uses their own `mcp remove` commands when the old server name exists. Grok Build has no Talking Stick MCP registration path; install is native skill plus hook only. Every cleanup run appends JSONL audit entries to `${TALKING_STICK_DATA_DIR}/update-migrations.log`.
|
|
174
175
|
|
|
175
|
-
Human CLI invocations also perform a silent best-effort sync for already-installed file-based skills in Claude Code, Codex, and OpenCode. If the installed skill is a copy, it is refreshed from the bundled skill; if it is a stale symlink, it is relinked. Missing harness config directories and missing skill installs are skipped. Gemini skills are managed by Gemini's own registry, so use `tt install gemini` after updating when needed.
|
|
176
|
+
Human CLI invocations also perform a silent best-effort sync for already-installed file-based skills in Claude Code, Codex, Grok Build, and OpenCode. If the installed skill is a copy, it is refreshed from the bundled skill; if it is a stale symlink, it is relinked. Missing harness config directories and missing skill installs are skipped. Gemini skills are managed by Gemini's own registry, so use `tt install gemini` after updating when needed.
|
|
176
177
|
|
|
177
178
|
## Human CLI
|
|
178
179
|
|
|
@@ -189,7 +190,7 @@ tt state [path] # full room state
|
|
|
189
190
|
tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent] # room event log; --wait/--follow long-polls
|
|
190
191
|
tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR] # send an OOB message
|
|
191
192
|
tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR] # receive OOB messages
|
|
192
|
-
tt instructions show [path] [--harness claude|codex|gemini|opencode|all] [--scope effective|bundled|user|project] # show collaboration prompt
|
|
193
|
+
tt instructions show [path] [--harness claude|codex|gemini|grok|opencode|all] [--scope effective|bundled|user|project] # show collaboration prompt
|
|
193
194
|
tt instructions edit [path] [--user|--project] # edit user or project prompt
|
|
194
195
|
tt instructions reset [path] (--user|--project) # delete a user or project prompt
|
|
195
196
|
tt release [path] --status TEXT --next-action TEXT # normal handoff
|
|
@@ -216,7 +217,8 @@ By default, `tt` behaves like a human CLI and resolves to `human:<username>` onl
|
|
|
216
217
|
|
|
217
218
|
Harness-aware CLI identity is resolved before the human fallback:
|
|
218
219
|
|
|
219
|
-
- Known harness environment markers such as `CLAUDECODE=1`, `CODEX_THREAD_ID`, `GEMINI_CLI=1`, or `OPENCODE=1` make `tt` derive a harness-style identity automatically.
|
|
220
|
+
- Known harness environment markers such as `CLAUDECODE=1`, `CODEX_THREAD_ID`, `GEMINI_CLI=1`, `CMUX_AGENT_LAUNCH_KIND=grok`, or `OPENCODE=1` make `tt` derive a harness-style identity automatically. The cmux Grok marker is optional; Grok Build also works without cmux by walking process ancestry for a `grok` root process.
|
|
221
|
+
- Grok Build's installed hook records hook-only `GROK_SESSION_ID` context into `${TALKING_STICK_DATA_DIR}/grok-sessions.jsonl`, letting later Grok-launched `tt` calls upgrade from process identity to the real Grok session id. `GROK_SESSION_ID` by itself is not treated as a normal shell marker, and the hook is not required for basic Grok detection.
|
|
220
222
|
- Set `TT_HARNESS_AGENT_ID=<agent-id>` if the harness wants to export the exact agent id directly.
|
|
221
223
|
- Set `TT_HARNESS_EXPORT=1` only when you need ancestry-based harness detection without a known harness environment marker.
|
|
222
224
|
|
package/dist/cli/event-stream.js
CHANGED
|
@@ -20,7 +20,11 @@ export async function runEventStream(runtime, parsed, identity, roomId, options)
|
|
|
20
20
|
event_type: options.event_type,
|
|
21
21
|
target_agent_id: targetAgentId,
|
|
22
22
|
from_agent_id: fromAgentId,
|
|
23
|
-
max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0
|
|
23
|
+
max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0,
|
|
24
|
+
// Carry the caller's identity so a sustained self-receiver registers and
|
|
25
|
+
// refreshes presence (issue #29 Defect 1) — a `tt events --follow` /
|
|
26
|
+
// `--wait` watcher stays visible even if it never ran `tt join`.
|
|
27
|
+
process_metadata: identity.process_metadata
|
|
24
28
|
};
|
|
25
29
|
if (!follow) {
|
|
26
30
|
const result = await runtime.commands.waitForEvents(waitInput);
|
|
@@ -38,7 +42,8 @@ export function resolveOptionalAgentSelector(runtime, identity, roomId, raw) {
|
|
|
38
42
|
export function resolveAgentSelector(runtime, identity, roomId, raw) {
|
|
39
43
|
const members = runtime.commands.getRoomState({
|
|
40
44
|
room_id: roomId,
|
|
41
|
-
agent_id: identity.agent_id
|
|
45
|
+
agent_id: identity.agent_id,
|
|
46
|
+
process_metadata: identity.process_metadata
|
|
42
47
|
}).members;
|
|
43
48
|
const exact = members.find((member) => member.agent_id === raw);
|
|
44
49
|
if (exact) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createSystemProcessInspector } from "../process-utils.js";
|
|
2
|
+
import { appendGrokSessionRecord } from "../grok-session-store.js";
|
|
3
|
+
import { findHarnessRootInAncestry } from "../identity.js";
|
|
4
|
+
export async function runGrokSessionHookCommand(options = {}) {
|
|
5
|
+
try {
|
|
6
|
+
const env = options.env ?? process.env;
|
|
7
|
+
const input = parseHookInput(options.stdin ?? await readStdin());
|
|
8
|
+
const sessionId = firstNonEmptyString(env.GROK_SESSION_ID, input.sessionId);
|
|
9
|
+
const workspaceRoot = firstNonEmptyString(env.GROK_WORKSPACE_ROOT, env.CLAUDE_PROJECT_DIR, input.workspaceRoot);
|
|
10
|
+
if (!sessionId || !workspaceRoot) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const event = firstNonEmptyString(env.GROK_HOOK_EVENT, input.hookEventName) ?? "unknown";
|
|
14
|
+
const inspector = options.inspector ?? createSystemProcessInspector();
|
|
15
|
+
const parentPid = options.parentPid ?? process.ppid;
|
|
16
|
+
const parentInspection = inspector.inspect(parentPid);
|
|
17
|
+
const grokRoot = findHarnessRootInAncestry("grok", parentPid, parentInspection, inspector, 20);
|
|
18
|
+
const record = {
|
|
19
|
+
source: "grok_hook",
|
|
20
|
+
grok_session_id: sessionId,
|
|
21
|
+
workspace_root: workspaceRoot,
|
|
22
|
+
cwd: firstNonEmptyString(input.cwd),
|
|
23
|
+
event,
|
|
24
|
+
observed_at: firstNonEmptyString(input.timestamp) ??
|
|
25
|
+
(options.now ?? new Date()).toISOString(),
|
|
26
|
+
grok_pid: grokRoot?.pid ?? null,
|
|
27
|
+
grok_process_started_at: grokRoot?.startTime ?? null
|
|
28
|
+
};
|
|
29
|
+
appendGrokSessionRecord(record, { logPath: options.logPath });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Grok hooks must fail open. Identity can fall back to pid-root detection
|
|
33
|
+
// when the hook cannot record a session row.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parseHookInput(raw) {
|
|
37
|
+
const trimmed = raw.trim();
|
|
38
|
+
if (!trimmed)
|
|
39
|
+
return {};
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(trimmed);
|
|
42
|
+
return isObjectRecord(parsed) ? parsed : {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function readStdin() {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let raw = "";
|
|
51
|
+
process.stdin.setEncoding("utf8");
|
|
52
|
+
process.stdin.on("data", (chunk) => {
|
|
53
|
+
raw += chunk;
|
|
54
|
+
});
|
|
55
|
+
process.stdin.on("error", reject);
|
|
56
|
+
process.stdin.on("end", () => resolve(raw));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function firstNonEmptyString(...values) {
|
|
60
|
+
for (const value of values) {
|
|
61
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
62
|
+
return value.trim();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function isObjectRecord(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
package/dist/cli/guardian.js
CHANGED
|
@@ -14,11 +14,12 @@ export async function runGuardCommand(parsed) {
|
|
|
14
14
|
displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
|
|
15
15
|
sessionKind: "human_guardian"
|
|
16
16
|
});
|
|
17
|
+
const harnessMetadata = parseHarnessMetadataOptions(parsed);
|
|
17
18
|
const identity = {
|
|
18
19
|
...baseIdentity,
|
|
19
20
|
process_metadata: {
|
|
20
21
|
...baseIdentity.process_metadata,
|
|
21
|
-
...
|
|
22
|
+
...harnessMetadata
|
|
22
23
|
}
|
|
23
24
|
};
|
|
24
25
|
const runtime = createRuntime();
|
|
@@ -32,8 +33,28 @@ export async function runGuardCommand(parsed) {
|
|
|
32
33
|
expected_turn_id: parseRequiredInteger(parsed, "turn-id")
|
|
33
34
|
};
|
|
34
35
|
const intervalMs = joined.policy.heartbeatIntervalMs;
|
|
36
|
+
const harnessRef = {
|
|
37
|
+
pid: harnessMetadata.harness_pid,
|
|
38
|
+
process_started_at: harnessMetadata.harness_process_started_at
|
|
39
|
+
};
|
|
40
|
+
const inspector = createSystemProcessInspector();
|
|
35
41
|
process.stdout.write(`${GUARD_READY}\n`);
|
|
36
42
|
const timer = setInterval(() => {
|
|
43
|
+
// Tier-1 stale-guardian purge: if our own harness process is provably
|
|
44
|
+
// gone, surrender the turn instead of renewing the lease forever. This is
|
|
45
|
+
// the definitive case (no timeout): an orphaned guardian must not pin the
|
|
46
|
+
// stick once the harness it represents has exited. `unknown`/`alive` both
|
|
47
|
+
// fall through to the normal heartbeat; we only act on a definite `gone`.
|
|
48
|
+
if (checkGuardianLiveness(harnessRef, inspector) === "gone") {
|
|
49
|
+
try {
|
|
50
|
+
runtime.commands.relinquishOwnership(identity, heartbeatInput);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Best effort: a takeover or graceful release may have already moved
|
|
54
|
+
// the turn on. Either way the harness is gone, so we exit.
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
37
58
|
try {
|
|
38
59
|
runtime.commands.heartbeat(identity, heartbeatInput);
|
|
39
60
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planUninstall, runAction } from "../install.js";
|
|
2
|
+
import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planGrokSessionHookInstall, planGrokSessionHookUninstall, planUninstall, runAction } from "../install.js";
|
|
3
3
|
import { planSkillInstall, planSkillUninstall } from "../skill-install.js";
|
|
4
4
|
import { resolveDataDir } from "../config.js";
|
|
5
5
|
import { FileAuditLog, defaultAuditLogPath } from "../install-audit.js";
|
|
@@ -145,7 +145,7 @@ function resolveSkillInstallLinkMode(parsed) {
|
|
|
145
145
|
return true;
|
|
146
146
|
}
|
|
147
147
|
function planInstallActions(harnesses, installOptions) {
|
|
148
|
-
return harnesses.
|
|
148
|
+
return harnesses.flatMap((harness) => planInstallActionsForHarness(harness, installOptions));
|
|
149
149
|
}
|
|
150
150
|
function planUninstallActions(harnesses, installOptions) {
|
|
151
151
|
return harnesses.flatMap((harness) => [
|
|
@@ -153,6 +153,14 @@ function planUninstallActions(harnesses, installOptions) {
|
|
|
153
153
|
...installOptions,
|
|
154
154
|
skipMissing: false
|
|
155
155
|
}),
|
|
156
|
+
...(harness === "grok"
|
|
157
|
+
? [
|
|
158
|
+
planGrokSessionHookUninstall({
|
|
159
|
+
...installOptions,
|
|
160
|
+
skipMissing: false
|
|
161
|
+
})
|
|
162
|
+
]
|
|
163
|
+
: []),
|
|
156
164
|
planUninstall(harness, installOptions)
|
|
157
165
|
]);
|
|
158
166
|
}
|
|
@@ -160,14 +168,31 @@ function planCleanupActions(harnesses, installOptions) {
|
|
|
160
168
|
return harnesses.map((harness) => planUninstall(harness, installOptions));
|
|
161
169
|
}
|
|
162
170
|
async function runSkillInstall(harness, installOptions) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
return [skillResult];
|
|
171
|
+
const actions = planInstallActionsForHarness(harness, installOptions);
|
|
172
|
+
return Promise.all(actions.map((action) => runAction(action, installOptions)));
|
|
166
173
|
}
|
|
167
174
|
async function runSkillUninstall(harness, installOptions) {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
const actions = [
|
|
176
|
+
planSkillUninstall(harness, {
|
|
177
|
+
...installOptions,
|
|
178
|
+
skipMissing: false
|
|
179
|
+
}),
|
|
180
|
+
...(harness === "grok"
|
|
181
|
+
? [
|
|
182
|
+
planGrokSessionHookUninstall({
|
|
183
|
+
...installOptions,
|
|
184
|
+
skipMissing: false
|
|
185
|
+
})
|
|
186
|
+
]
|
|
187
|
+
: [])
|
|
188
|
+
];
|
|
189
|
+
return Promise.all(actions.map((action) => runAction(action, installOptions)));
|
|
190
|
+
}
|
|
191
|
+
function planInstallActionsForHarness(harness, installOptions) {
|
|
192
|
+
return [
|
|
193
|
+
planSkillInstall(harness, installOptions),
|
|
194
|
+
...(harness === "grok" ? [planGrokSessionHookInstall(installOptions)] : [])
|
|
195
|
+
];
|
|
171
196
|
}
|
|
172
197
|
async function runCleanup(harnesses, reason, installOptions) {
|
|
173
198
|
const dataDir = resolveDataDir();
|
package/dist/cli/output.js
CHANGED
|
@@ -164,7 +164,7 @@ Commands:
|
|
|
164
164
|
tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]
|
|
165
165
|
tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR]
|
|
166
166
|
tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR]
|
|
167
|
-
tt instructions show [path] [--harness claude|codex|gemini|opencode|all] [--scope effective|bundled|user|project]
|
|
167
|
+
tt instructions show [path] [--harness claude|codex|gemini|grok|opencode|all] [--scope effective|bundled|user|project]
|
|
168
168
|
tt instructions edit [path] [--user|--project]
|
|
169
169
|
tt instructions reset [path] (--user|--project)
|
|
170
170
|
tt release [path] (--status TEXT --next-action TEXT | --stdin)
|
package/dist/cli/registry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runGuardCommand } from "./guardian.js";
|
|
2
|
+
import { runGrokSessionHookCommand } from "./grok-session-hook.js";
|
|
2
3
|
import { runInstallCommand, runMcpMigrationCommand, runSelfUpdateCommand, runUninstallCommand } from "./install-commands.js";
|
|
3
4
|
import { handleInstructionsCommand } from "./instructions-commands.js";
|
|
4
5
|
import { handleMsgCommand } from "./msg-commands.js";
|
|
@@ -15,6 +16,15 @@ export const COMMAND_REGISTRY = [
|
|
|
15
16
|
description: "Run an internal lease heartbeat guardian.",
|
|
16
17
|
handler: ({ parsed }) => runGuardCommand(parsed)
|
|
17
18
|
},
|
|
19
|
+
{
|
|
20
|
+
name: "grok-session-hook",
|
|
21
|
+
needsRuntime: false,
|
|
22
|
+
startupMaintenance: false,
|
|
23
|
+
internal: true,
|
|
24
|
+
usage: "tt grok-session-hook",
|
|
25
|
+
description: "Record Grok hook session context for identity resolution.",
|
|
26
|
+
handler: () => runGrokSessionHookCommand()
|
|
27
|
+
},
|
|
18
28
|
{
|
|
19
29
|
name: "install",
|
|
20
30
|
needsRuntime: false,
|
|
@@ -95,7 +95,8 @@ export function handleStateCommand(runtime, parsed) {
|
|
|
95
95
|
const session = resolveSessionForReads(runtime, parsed, identity);
|
|
96
96
|
const state = runtime.commands.getRoomState({
|
|
97
97
|
room_id: session.room_id,
|
|
98
|
-
agent_id: identity.agent_id
|
|
98
|
+
agent_id: identity.agent_id,
|
|
99
|
+
process_metadata: identity.process_metadata
|
|
99
100
|
});
|
|
100
101
|
printResult(parsed, { room: state.room, members: state.members }, () => {
|
|
101
102
|
const lines = [
|
|
@@ -144,7 +145,8 @@ export async function handleEventsCommand(runtime, parsed) {
|
|
|
144
145
|
room_id: session.room_id,
|
|
145
146
|
agent_id: identity.agent_id,
|
|
146
147
|
after_event_seq: parseOptionalInteger(parsed, "after"),
|
|
147
|
-
limit: parseOptionalInteger(parsed, "limit")
|
|
148
|
+
limit: parseOptionalInteger(parsed, "limit"),
|
|
149
|
+
process_metadata: identity.process_metadata
|
|
148
150
|
});
|
|
149
151
|
printResult(parsed, events, () => {
|
|
150
152
|
if (events.length === 0) {
|
|
@@ -245,7 +245,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
|
|
|
245
245
|
}
|
|
246
246
|
const state = runtime.commands.getRoomState({
|
|
247
247
|
room_id: session.room_id,
|
|
248
|
-
agent_id: identity.agent_id
|
|
248
|
+
agent_id: identity.agent_id,
|
|
249
|
+
process_metadata: identity.process_metadata
|
|
249
250
|
});
|
|
250
251
|
const normalizedSelector = selector.toLowerCase();
|
|
251
252
|
const candidates = state.members.filter((member) => {
|
|
@@ -265,7 +266,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
|
|
|
265
266
|
const events = runtime.commands.getRoomEvents({
|
|
266
267
|
room_id: session.room_id,
|
|
267
268
|
agent_id: identity.agent_id,
|
|
268
|
-
limit: 500
|
|
269
|
+
limit: 500,
|
|
270
|
+
process_metadata: identity.process_metadata
|
|
269
271
|
});
|
|
270
272
|
return pickFairAssignmentCandidate(candidates, events).agent_id;
|
|
271
273
|
}
|
package/dist/commands.js
CHANGED
|
@@ -41,7 +41,8 @@ export class TalkingStickCommands {
|
|
|
41
41
|
auto_claim: input.auto_claim,
|
|
42
42
|
include_events: input.include_events,
|
|
43
43
|
after_event_seq: input.after_event_seq,
|
|
44
|
-
target_agent_id: input.target_agent_id
|
|
44
|
+
target_agent_id: input.target_agent_id,
|
|
45
|
+
process_metadata: identity.process_metadata
|
|
45
46
|
});
|
|
46
47
|
}
|
|
47
48
|
heartbeat(identity, input) {
|
|
@@ -52,6 +53,14 @@ export class TalkingStickCommands {
|
|
|
52
53
|
expected_turn_id: input.expected_turn_id
|
|
53
54
|
});
|
|
54
55
|
}
|
|
56
|
+
relinquishOwnership(identity, input) {
|
|
57
|
+
return this.service.relinquishOwnership({
|
|
58
|
+
agent_id: identity.agent_id,
|
|
59
|
+
room_id: input.room_id,
|
|
60
|
+
lease_id: input.lease_id,
|
|
61
|
+
expected_turn_id: input.expected_turn_id
|
|
62
|
+
});
|
|
63
|
+
}
|
|
55
64
|
releaseStick(identity, input) {
|
|
56
65
|
return this.service.releaseStick({
|
|
57
66
|
agent_id: identity.agent_id,
|
|
@@ -92,7 +101,8 @@ export class TalkingStickCommands {
|
|
|
92
101
|
room_id: input.room_id,
|
|
93
102
|
body: input.body,
|
|
94
103
|
to_agent_id: input.to_agent_id,
|
|
95
|
-
delivery_hint: input.delivery_hint
|
|
104
|
+
delivery_hint: input.delivery_hint,
|
|
105
|
+
process_metadata: identity.process_metadata
|
|
96
106
|
});
|
|
97
107
|
}
|
|
98
108
|
waitForEvents(input) {
|
|
@@ -106,7 +116,8 @@ export class TalkingStickCommands {
|
|
|
106
116
|
agent_id: identity.agent_id,
|
|
107
117
|
room_id: input.room_id,
|
|
108
118
|
body: input.body,
|
|
109
|
-
turn_id: input.turn_id
|
|
119
|
+
turn_id: input.turn_id,
|
|
120
|
+
process_metadata: identity.process_metadata
|
|
110
121
|
});
|
|
111
122
|
}
|
|
112
123
|
listNotes(identity, input) {
|
|
@@ -115,7 +126,8 @@ export class TalkingStickCommands {
|
|
|
115
126
|
agent_id: identity?.agent_id,
|
|
116
127
|
after_note_id: input.after_note_id,
|
|
117
128
|
include_resolved: input.include_resolved,
|
|
118
|
-
limit: input.limit
|
|
129
|
+
limit: input.limit,
|
|
130
|
+
process_metadata: identity?.process_metadata
|
|
119
131
|
});
|
|
120
132
|
}
|
|
121
133
|
}
|
package/dist/config.js
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveDataDir } from "./config.js";
|
|
4
|
+
export const DEFAULT_GROK_SESSION_RECORD_MAX_AGE_MS = 4 * 60 * 60 * 1000;
|
|
5
|
+
export function resolveGrokSessionLogPath(options = {}) {
|
|
6
|
+
return path.join(resolveDataDir(options), "grok-sessions.jsonl");
|
|
7
|
+
}
|
|
8
|
+
export function appendGrokSessionRecord(record, options = {}) {
|
|
9
|
+
const logPath = options.logPath ?? resolveGrokSessionLogPath(options.dataDirOptions ?? {});
|
|
10
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
11
|
+
fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
12
|
+
}
|
|
13
|
+
export function readGrokSessionRecords(logPath) {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(logPath, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT") {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
const records = [];
|
|
25
|
+
for (const line of raw.split("\n")) {
|
|
26
|
+
if (!line.trim())
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(line);
|
|
30
|
+
const record = parseGrokSessionRecord(parsed);
|
|
31
|
+
if (record)
|
|
32
|
+
records.push(record);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Hook logs are append-only and best-effort; one bad line should not
|
|
36
|
+
// break identity resolution for the whole session.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return records;
|
|
40
|
+
}
|
|
41
|
+
export function findGrokSessionRecord(input) {
|
|
42
|
+
const workspaceRoot = normalizeWorkspaceRoot(input.workspaceRoot);
|
|
43
|
+
if (!workspaceRoot)
|
|
44
|
+
return null;
|
|
45
|
+
const logPath = input.logPath ?? resolveGrokSessionLogPath();
|
|
46
|
+
const nowMs = input.now?.getTime() ?? Date.now();
|
|
47
|
+
const maxAgeMs = input.maxAgeMs ?? DEFAULT_GROK_SESSION_RECORD_MAX_AGE_MS;
|
|
48
|
+
const records = readGrokSessionRecords(logPath);
|
|
49
|
+
const endedSessionIds = new Set();
|
|
50
|
+
const workspaceCandidates = [];
|
|
51
|
+
for (const record of records.slice().reverse()) {
|
|
52
|
+
if (normalizeWorkspaceRoot(record.workspace_root) !== workspaceRoot) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (isStaleRecord(record, nowMs, maxAgeMs)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (isGrokSessionEndEvent(record.event)) {
|
|
59
|
+
endedSessionIds.add(record.grok_session_id);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (endedSessionIds.has(record.grok_session_id)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
workspaceCandidates.push(record);
|
|
66
|
+
if (input.grokPid != null &&
|
|
67
|
+
input.grokProcessStartedAt != null &&
|
|
68
|
+
record.grok_pid === input.grokPid &&
|
|
69
|
+
record.grok_process_started_at === input.grokProcessStartedAt) {
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const uniqueSessionIds = new Set(workspaceCandidates.map((record) => record.grok_session_id));
|
|
74
|
+
if (uniqueSessionIds.size === 1) {
|
|
75
|
+
return workspaceCandidates[0] ?? null;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
export function isGrokSessionEndEvent(event) {
|
|
80
|
+
return normalizeEventName(event) === "sessionend";
|
|
81
|
+
}
|
|
82
|
+
function parseGrokSessionRecord(value) {
|
|
83
|
+
if (!isObjectRecord(value))
|
|
84
|
+
return null;
|
|
85
|
+
if (value.source !== "grok_hook")
|
|
86
|
+
return null;
|
|
87
|
+
const grokSessionId = nonEmptyString(value.grok_session_id);
|
|
88
|
+
const workspaceRoot = nonEmptyString(value.workspace_root);
|
|
89
|
+
const event = nonEmptyString(value.event);
|
|
90
|
+
const observedAt = nonEmptyString(value.observed_at);
|
|
91
|
+
if (!grokSessionId || !workspaceRoot || !event || !observedAt) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
source: "grok_hook",
|
|
96
|
+
grok_session_id: grokSessionId,
|
|
97
|
+
workspace_root: workspaceRoot,
|
|
98
|
+
cwd: nullableString(value.cwd),
|
|
99
|
+
event,
|
|
100
|
+
observed_at: observedAt,
|
|
101
|
+
grok_pid: nullableInteger(value.grok_pid),
|
|
102
|
+
grok_process_started_at: nullableString(value.grok_process_started_at)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function isStaleRecord(record, nowMs, maxAgeMs) {
|
|
106
|
+
const observedAtMs = Date.parse(record.observed_at);
|
|
107
|
+
if (Number.isNaN(observedAtMs))
|
|
108
|
+
return true;
|
|
109
|
+
return nowMs - observedAtMs > maxAgeMs;
|
|
110
|
+
}
|
|
111
|
+
function normalizeWorkspaceRoot(value) {
|
|
112
|
+
const trimmed = value?.trim();
|
|
113
|
+
if (!trimmed)
|
|
114
|
+
return null;
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync.native(trimmed);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return path.resolve(trimmed);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function normalizeEventName(event) {
|
|
123
|
+
return event.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
124
|
+
}
|
|
125
|
+
function nonEmptyString(value) {
|
|
126
|
+
return typeof value === "string" && value.trim().length > 0
|
|
127
|
+
? value
|
|
128
|
+
: null;
|
|
129
|
+
}
|
|
130
|
+
function nullableString(value) {
|
|
131
|
+
return typeof value === "string" && value.trim().length > 0
|
|
132
|
+
? value
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
function nullableInteger(value) {
|
|
136
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
137
|
+
}
|
|
138
|
+
function isObjectRecord(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|