talking-stick 0.4.10 → 0.4.11
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 -1
- package/dist/atomic-write.js +21 -0
- package/dist/cli/guardian.js +45 -9
- package/dist/cli/install-commands.js +1 -11
- package/dist/cli/instructions-commands.js +0 -17
- package/dist/cli/msg-commands.js +0 -9
- package/dist/cli/parser.js +30 -7
- package/dist/cli/room-commands.js +1 -3
- package/dist/cli/turn-commands.js +1 -3
- package/dist/cli.js +18 -6
- package/dist/grok-session-store.js +3 -0
- package/dist/install.js +23 -14
- package/dist/instructions.js +10 -3
- package/dist/process-utils.js +5 -1
- package/dist/service.js +20 -11
- package/dist/session-store.js +2 -2
- package/dist/skill-install.js +32 -3
- package/docs/releases/0.4.11.md +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ For harnesses that only notice completed subprocesses, run `tt events --wait --a
|
|
|
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
169
|
- Grok Build: copied or linked into `~/.grok/skills/talking-stick`, plus a trusted global session hook at `~/.grok/hooks/talking-stick-session.json`
|
|
170
|
-
- OpenCode: copied or linked into `~/.opencode/skills/talking-stick`
|
|
170
|
+
- OpenCode: copied or linked into the resolved OpenCode config directory, normally `~/.config/opencode/skills/talking-stick` and honoring `XDG_CONFIG_HOME`
|
|
171
171
|
|
|
172
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.
|
|
173
173
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function writeFileAtomic(filePath, data) {
|
|
5
|
+
const directory = path.dirname(filePath);
|
|
6
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
7
|
+
const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`);
|
|
8
|
+
try {
|
|
9
|
+
fs.writeFileSync(tempPath, data);
|
|
10
|
+
fs.renameSync(tempPath, filePath);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
try {
|
|
14
|
+
fs.rmSync(tempPath, { force: true });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Best effort cleanup for failed writes.
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/cli/guardian.js
CHANGED
|
@@ -103,8 +103,46 @@ export async function spawnGuardian(input) {
|
|
|
103
103
|
const inspector = createSystemProcessInspector();
|
|
104
104
|
let stdout = "";
|
|
105
105
|
let stderr = "";
|
|
106
|
+
let settled = false;
|
|
107
|
+
const cleanup = () => {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
child.stdout?.removeAllListeners();
|
|
110
|
+
child.stderr?.removeAllListeners();
|
|
111
|
+
child.removeAllListeners("exit");
|
|
112
|
+
child.removeAllListeners("error");
|
|
113
|
+
child.stdout?.destroy();
|
|
114
|
+
child.stderr?.destroy();
|
|
115
|
+
};
|
|
116
|
+
const killChild = () => {
|
|
117
|
+
try {
|
|
118
|
+
child.kill("SIGTERM");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Best effort cleanup for a child that failed readiness.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const rejectOnce = (error, kill = false) => {
|
|
125
|
+
if (settled) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
settled = true;
|
|
129
|
+
if (kill) {
|
|
130
|
+
killChild();
|
|
131
|
+
}
|
|
132
|
+
cleanup();
|
|
133
|
+
reject(error);
|
|
134
|
+
};
|
|
135
|
+
const resolveOnce = (value) => {
|
|
136
|
+
if (settled) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
settled = true;
|
|
140
|
+
cleanup();
|
|
141
|
+
child.unref();
|
|
142
|
+
resolve(value);
|
|
143
|
+
};
|
|
106
144
|
const timeout = setTimeout(() => {
|
|
107
|
-
|
|
145
|
+
rejectOnce(new Error("Guardian did not signal readiness in time."), true);
|
|
108
146
|
}, GUARD_READY_TIMEOUT_MS);
|
|
109
147
|
child.stdout?.setEncoding("utf8");
|
|
110
148
|
child.stderr?.setEncoding("utf8");
|
|
@@ -113,15 +151,11 @@ export async function spawnGuardian(input) {
|
|
|
113
151
|
if (!stdout.includes(GUARD_READY)) {
|
|
114
152
|
return;
|
|
115
153
|
}
|
|
116
|
-
clearTimeout(timeout);
|
|
117
|
-
child.stdout?.destroy();
|
|
118
|
-
child.stderr?.destroy();
|
|
119
|
-
child.unref();
|
|
120
154
|
if (!child.pid) {
|
|
121
|
-
|
|
155
|
+
rejectOnce(new Error("Guardian started without a PID."), true);
|
|
122
156
|
return;
|
|
123
157
|
}
|
|
124
|
-
|
|
158
|
+
resolveOnce({
|
|
125
159
|
pid: child.pid,
|
|
126
160
|
process_started_at: inspector.inspect(child.pid)?.startTime ?? null
|
|
127
161
|
});
|
|
@@ -130,8 +164,10 @@ export async function spawnGuardian(input) {
|
|
|
130
164
|
stderr += chunk;
|
|
131
165
|
});
|
|
132
166
|
child.on("exit", (code) => {
|
|
133
|
-
|
|
134
|
-
|
|
167
|
+
rejectOnce(new Error(`Guardian exited before readiness (code ${code ?? "unknown"}): ${stderr.trim()}`));
|
|
168
|
+
});
|
|
169
|
+
child.on("error", (error) => {
|
|
170
|
+
rejectOnce(error instanceof Error ? error : new Error(String(error)));
|
|
135
171
|
});
|
|
136
172
|
});
|
|
137
173
|
}
|
|
@@ -6,11 +6,8 @@ import { FileAuditLog, defaultAuditLogPath } from "../install-audit.js";
|
|
|
6
6
|
import { removeStaleMcpRegistrations } from "../install-migration.js";
|
|
7
7
|
import { detectInstallSource, isPackageManager, planSelfUpdate, resolveCurrentBinaryPath } from "../self-update.js";
|
|
8
8
|
import { readPackageVersion, runStaleMcpCleanup } from "../update-migration.js";
|
|
9
|
-
import { getStringOption, hasOption
|
|
9
|
+
import { getStringOption, hasOption } from "./parser.js";
|
|
10
10
|
export async function runInstallCommand(parsed) {
|
|
11
|
-
normalizeBooleanFlag(parsed, "print");
|
|
12
|
-
normalizeBooleanFlag(parsed, "copy");
|
|
13
|
-
normalizeBooleanFlag(parsed, "link");
|
|
14
11
|
const harnesses = selectHarnesses(parsed);
|
|
15
12
|
const dryRun = hasOption(parsed, "print");
|
|
16
13
|
const installOptions = {
|
|
@@ -32,7 +29,6 @@ export async function runInstallCommand(parsed) {
|
|
|
32
29
|
printInstructionHint(results);
|
|
33
30
|
}
|
|
34
31
|
export async function runUninstallCommand(parsed) {
|
|
35
|
-
normalizeBooleanFlag(parsed, "print");
|
|
36
32
|
const harnesses = selectHarnesses(parsed);
|
|
37
33
|
const dryRun = hasOption(parsed, "print");
|
|
38
34
|
const installOptions = { skipMissing: true };
|
|
@@ -48,9 +44,6 @@ export async function runUninstallCommand(parsed) {
|
|
|
48
44
|
reportCleanupResults(await runCleanup(harnesses, "uninstall", installOptions), "uninstall");
|
|
49
45
|
}
|
|
50
46
|
export async function runInstallSkillCommand(parsed) {
|
|
51
|
-
normalizeBooleanFlag(parsed, "print");
|
|
52
|
-
normalizeBooleanFlag(parsed, "copy");
|
|
53
|
-
normalizeBooleanFlag(parsed, "link");
|
|
54
47
|
const harnesses = selectHarnesses(parsed);
|
|
55
48
|
const dryRun = hasOption(parsed, "print");
|
|
56
49
|
const link = resolveSkillInstallLinkMode(parsed);
|
|
@@ -66,7 +59,6 @@ export async function runInstallSkillCommand(parsed) {
|
|
|
66
59
|
reportInstallResults(results, "install");
|
|
67
60
|
}
|
|
68
61
|
export async function runUninstallSkillCommand(parsed) {
|
|
69
|
-
normalizeBooleanFlag(parsed, "print");
|
|
70
62
|
const harnesses = selectHarnesses(parsed);
|
|
71
63
|
const dryRun = hasOption(parsed, "print");
|
|
72
64
|
const installOptions = { skipMissing: true };
|
|
@@ -81,7 +73,6 @@ export async function runUninstallSkillCommand(parsed) {
|
|
|
81
73
|
reportInstallResults(results, "uninstall");
|
|
82
74
|
}
|
|
83
75
|
export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
|
|
84
|
-
normalizeBooleanFlag(parsed, "print");
|
|
85
76
|
const dryRun = hasOption(parsed, "print");
|
|
86
77
|
const managerOverride = getStringOption(parsed, "manager");
|
|
87
78
|
let source;
|
|
@@ -121,7 +112,6 @@ export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
|
|
|
121
112
|
process.stdout.write("Done. Restart any long-running harness sessions to pick up the new tt.\n");
|
|
122
113
|
}
|
|
123
114
|
export async function runMcpMigrationCommand(parsed) {
|
|
124
|
-
normalizeBooleanFlag(parsed, "quiet");
|
|
125
115
|
const reason = parseAuditReason(getStringOption(parsed, "reason") ?? "manual");
|
|
126
116
|
const quiet = hasOption(parsed, "quiet");
|
|
127
117
|
const cleanup = await runStaleMcpCleanup({
|
|
@@ -24,8 +24,6 @@ export async function handleInstructionsCommand(parsed) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
function handleInstructionsShowCommand(parsed) {
|
|
27
|
-
repairBooleanFlag(parsed, "json", 0);
|
|
28
|
-
repairBooleanFlag(parsed, "text", 0);
|
|
29
27
|
const contextPath = resolveContextPathArg(parsed);
|
|
30
28
|
const scope = parseInstructionScope(getStringOption(parsed, "scope"));
|
|
31
29
|
const identity = deriveCliIdentity(parsed);
|
|
@@ -45,10 +43,6 @@ function handleInstructionsShowCommand(parsed) {
|
|
|
45
43
|
});
|
|
46
44
|
}
|
|
47
45
|
async function handleInstructionsEditCommand(parsed) {
|
|
48
|
-
repairBooleanFlag(parsed, "json", 0);
|
|
49
|
-
repairBooleanFlag(parsed, "text", 0);
|
|
50
|
-
repairBooleanFlag(parsed, "user", 0);
|
|
51
|
-
repairBooleanFlag(parsed, "project", 0);
|
|
52
46
|
const contextPath = resolveContextPathArg(parsed);
|
|
53
47
|
const scope = resolveEditableScope(parsed, false);
|
|
54
48
|
const result = await editInstructions({
|
|
@@ -66,10 +60,6 @@ async function handleInstructionsEditCommand(parsed) {
|
|
|
66
60
|
});
|
|
67
61
|
}
|
|
68
62
|
function handleInstructionsResetCommand(parsed) {
|
|
69
|
-
repairBooleanFlag(parsed, "json", 0);
|
|
70
|
-
repairBooleanFlag(parsed, "text", 0);
|
|
71
|
-
repairBooleanFlag(parsed, "user", 0);
|
|
72
|
-
repairBooleanFlag(parsed, "project", 0);
|
|
73
63
|
const contextPath = resolveContextPathArg(parsed);
|
|
74
64
|
const scope = resolveEditableScope(parsed, true);
|
|
75
65
|
const result = resetInstructions({
|
|
@@ -104,10 +94,3 @@ function resolveContextPathArg(parsed) {
|
|
|
104
94
|
}
|
|
105
95
|
return pathOption ?? parsed.positionals[0] ?? process.cwd();
|
|
106
96
|
}
|
|
107
|
-
function repairBooleanFlag(parsed, key, insertAt) {
|
|
108
|
-
const value = parsed.options.get(key);
|
|
109
|
-
if (typeof value === "string") {
|
|
110
|
-
parsed.positionals.splice(insertAt, 0, value);
|
|
111
|
-
parsed.options.set(key, true);
|
|
112
|
-
}
|
|
113
|
-
}
|
package/dist/cli/msg-commands.js
CHANGED
|
@@ -29,8 +29,6 @@ async function handleMsgSendCommand(runtime, parsed) {
|
|
|
29
29
|
const identity = deriveCliIdentity(parsed);
|
|
30
30
|
const session = resolveSessionForNotes(runtime, parsed, identity);
|
|
31
31
|
const usesRoomFlag = hasOption(parsed, "room");
|
|
32
|
-
repairBooleanFlag(parsed, "room", 0);
|
|
33
|
-
repairBooleanFlag(parsed, "interrupt", usesRoomFlag ? 0 : 1);
|
|
34
32
|
const recipientSelector = usesRoomFlag ? "room" : parsed.positionals[0];
|
|
35
33
|
if (!recipientSelector) {
|
|
36
34
|
throw new Error("Usage: tt msg send <recipient|room> <body...> [--interrupt] [--stdin].");
|
|
@@ -69,13 +67,6 @@ async function handleMsgRecvCommand(runtime, parsed) {
|
|
|
69
67
|
force_tail_cursor: false
|
|
70
68
|
});
|
|
71
69
|
}
|
|
72
|
-
function repairBooleanFlag(parsed, key, insertAt) {
|
|
73
|
-
const value = parsed.options.get(key);
|
|
74
|
-
if (typeof value === "string") {
|
|
75
|
-
parsed.positionals.splice(insertAt, 0, value);
|
|
76
|
-
parsed.options.set(key, true);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
70
|
function shortEventId(eventId) {
|
|
80
71
|
return eventId.slice(0, 8);
|
|
81
72
|
}
|
package/dist/cli/parser.js
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
const BOOLEAN_FLAGS = new Set([
|
|
2
|
+
"all",
|
|
3
|
+
"copy",
|
|
4
|
+
"events",
|
|
5
|
+
"explain",
|
|
6
|
+
"follow",
|
|
7
|
+
"force",
|
|
8
|
+
"force-new",
|
|
9
|
+
"help",
|
|
10
|
+
"interrupt",
|
|
11
|
+
"json",
|
|
12
|
+
"link",
|
|
13
|
+
"operator-requested",
|
|
14
|
+
"park",
|
|
15
|
+
"print",
|
|
16
|
+
"project",
|
|
17
|
+
"quiet",
|
|
18
|
+
"room",
|
|
19
|
+
"stdin",
|
|
20
|
+
"text",
|
|
21
|
+
"user",
|
|
22
|
+
"wait"
|
|
23
|
+
]);
|
|
1
24
|
export function parseCommand(argv) {
|
|
2
25
|
const [name = "", ...rest] = argv;
|
|
3
26
|
const options = new Map();
|
|
@@ -9,6 +32,10 @@ export function parseCommand(argv) {
|
|
|
9
32
|
continue;
|
|
10
33
|
}
|
|
11
34
|
const key = token.slice(2);
|
|
35
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
36
|
+
options.set(key, true);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
12
39
|
const next = rest[index + 1];
|
|
13
40
|
if (!next || next.startsWith("--")) {
|
|
14
41
|
options.set(key, true);
|
|
@@ -33,18 +60,14 @@ export function requireStringOption(parsed, key) {
|
|
|
33
60
|
}
|
|
34
61
|
return value;
|
|
35
62
|
}
|
|
36
|
-
export function normalizeBooleanFlag(parsed, key) {
|
|
37
|
-
const value = parsed.options.get(key);
|
|
38
|
-
if (typeof value === "string") {
|
|
39
|
-
parsed.positionals.unshift(value);
|
|
40
|
-
parsed.options.set(key, true);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
63
|
export function parseOptionalInteger(parsed, key) {
|
|
44
64
|
const value = getStringOption(parsed, key);
|
|
45
65
|
if (!value) {
|
|
46
66
|
return undefined;
|
|
47
67
|
}
|
|
68
|
+
if (!/^\d+$/.test(value)) {
|
|
69
|
+
throw new Error(`--${key} must be an integer.`);
|
|
70
|
+
}
|
|
48
71
|
const parsedValue = Number.parseInt(value, 10);
|
|
49
72
|
if (!Number.isInteger(parsedValue)) {
|
|
50
73
|
throw new Error(`--${key} must be an integer.`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { deriveCliIdentity, resolveCliIdentity } from "./identity.js";
|
|
2
2
|
import { removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath } from "../index.js";
|
|
3
3
|
import { stopGuardian } from "./guardian.js";
|
|
4
|
-
import { getStringOption, hasOption,
|
|
4
|
+
import { getStringOption, hasOption, parseOptionalInteger } from "./parser.js";
|
|
5
5
|
import { formatRelativeTime, printResult } from "./output.js";
|
|
6
6
|
import { parseEventTypeFilter, runEventStream } from "./event-stream.js";
|
|
7
7
|
import { resolveSessionForReads, upsertSessionFromJoin } from "./session.js";
|
|
@@ -129,8 +129,6 @@ export function handleStateCommand(runtime, parsed) {
|
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
131
|
export async function handleEventsCommand(runtime, parsed) {
|
|
132
|
-
normalizeBooleanFlag(parsed, "wait");
|
|
133
|
-
normalizeBooleanFlag(parsed, "follow");
|
|
134
132
|
const identity = deriveCliIdentity(parsed);
|
|
135
133
|
const session = resolveSessionForReads(runtime, parsed, identity);
|
|
136
134
|
if (hasOption(parsed, "wait") || hasOption(parsed, "follow")) {
|
|
@@ -2,13 +2,11 @@ import { clearCliSessionLease, createSystemProcessInspector, findCliSessionByRoo
|
|
|
2
2
|
import { checkGuardianLiveness, spawnGuardian, stopGuardian } from "./guardian.js";
|
|
3
3
|
import { resolveHandoff } from "./handoff.js";
|
|
4
4
|
import { deriveCliIdentity, resolveTakeoverReason, shouldUseOperatorOverride } from "./identity.js";
|
|
5
|
-
import { getStringOption, hasOption,
|
|
5
|
+
import { getStringOption, hasOption, parseRequiredInteger, parseWaitTimeout } from "./parser.js";
|
|
6
6
|
import { resolveTargetFilter } from "./event-stream.js";
|
|
7
7
|
import { formatWaitResult, printResult } from "./output.js";
|
|
8
8
|
import { requireLeaseSession, upsertSessionFromJoin } from "./session.js";
|
|
9
9
|
export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
|
|
10
|
-
normalizeBooleanFlag(parsed, "park");
|
|
11
|
-
normalizeBooleanFlag(parsed, "events");
|
|
12
10
|
const park = hasOption(parsed, "park");
|
|
13
11
|
const includeEvents = hasOption(parsed, "events");
|
|
14
12
|
const afterEventSeq = includeEvents
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { isProtocolError } from "./index.js";
|
|
6
6
|
import { parseCommand } from "./cli/parser.js";
|
|
7
|
-
import { printHelp } from "./cli/output.js";
|
|
7
|
+
import { printHelp, shouldUseJson } from "./cli/output.js";
|
|
8
8
|
import { getCommand } from "./cli/registry.js";
|
|
9
9
|
import { createRuntime } from "./cli/runtime.js";
|
|
10
10
|
import { runStartupMaintenance } from "./cli/startup-maintenance.js";
|
|
@@ -49,12 +49,24 @@ function isDirectExecution() {
|
|
|
49
49
|
}
|
|
50
50
|
if (isDirectExecution()) {
|
|
51
51
|
await runCli().catch((error) => {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
const parsed = parseCommand(process.argv.slice(2));
|
|
53
|
+
if (shouldUseJson(parsed)) {
|
|
54
|
+
const payload = isProtocolError(error)
|
|
55
|
+
? error.toJSON()
|
|
56
|
+
: {
|
|
57
|
+
error: "cli_error",
|
|
58
|
+
message: error instanceof Error ? error.message : String(error)
|
|
59
|
+
};
|
|
60
|
+
process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const message = isProtocolError(error)
|
|
55
64
|
? error.message
|
|
56
|
-
:
|
|
57
|
-
|
|
65
|
+
: error instanceof Error
|
|
66
|
+
? error.message
|
|
67
|
+
: String(error);
|
|
68
|
+
process.stderr.write(`${message}\n`);
|
|
69
|
+
}
|
|
58
70
|
process.exit(1);
|
|
59
71
|
});
|
|
60
72
|
}
|
|
@@ -70,6 +70,9 @@ export function findGrokSessionRecord(input) {
|
|
|
70
70
|
return record;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
if (input.grokPid != null && input.grokProcessStartedAt != null) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
73
76
|
const uniqueSessionIds = new Set(workspaceCandidates.map((record) => record.grok_session_id));
|
|
74
77
|
if (uniqueSessionIds.size === 1) {
|
|
75
78
|
return workspaceCandidates[0] ?? null;
|
package/dist/install.js
CHANGED
|
@@ -2,8 +2,10 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
5
6
|
export const SUPPORTED_HARNESSES = ["claude-code", "codex", "gemini", "grok", "opencode"];
|
|
6
7
|
export const DEFAULT_SERVER_NAME = "talking-stick";
|
|
8
|
+
// Legacy MCP command retained only to identify stale config entries for removal.
|
|
7
9
|
export const DEFAULT_SERVER_COMMAND = ["tt", "mcp"];
|
|
8
10
|
export const GROK_SESSION_HOOK_FILE = "talking-stick-session.json";
|
|
9
11
|
export const DEFAULT_GROK_SESSION_HOOK_COMMAND = ": talking-stick-grok-session-hook; if command -v tt >/dev/null 2>&1; then tt grok-session-hook >/dev/null 2>/dev/null || true; fi";
|
|
@@ -68,7 +70,7 @@ function defaultReadFile(filePath) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
function defaultWriteFile(filePath, data) {
|
|
71
|
-
|
|
73
|
+
writeFileAtomic(filePath, data);
|
|
72
74
|
}
|
|
73
75
|
function defaultEnsureDir(dirPath) {
|
|
74
76
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
@@ -196,7 +198,7 @@ export function planUninstall(harness, options = {}) {
|
|
|
196
198
|
operation: "uninstall",
|
|
197
199
|
serverName: resolved.serverName,
|
|
198
200
|
inspect: () => inspectOpencodeConfig(filePath, resolved),
|
|
199
|
-
apply: () => patchOpencodeConfig(filePath, resolved
|
|
201
|
+
apply: () => patchOpencodeConfig(filePath, resolved)
|
|
200
202
|
};
|
|
201
203
|
}
|
|
202
204
|
default:
|
|
@@ -281,29 +283,20 @@ function removeGrokSessionHook(filePath, resolved) {
|
|
|
281
283
|
throw error;
|
|
282
284
|
}
|
|
283
285
|
}
|
|
284
|
-
function patchOpencodeConfig(filePath, resolved
|
|
286
|
+
function patchOpencodeConfig(filePath, resolved) {
|
|
285
287
|
const existing = resolved.hooks.readFile(filePath);
|
|
286
288
|
if (resolved.skipMissing) {
|
|
287
289
|
const configDir = path.dirname(filePath);
|
|
288
290
|
if (!resolved.hooks.pathExists(configDir)) {
|
|
289
291
|
throw new MissingHarnessError(`opencode config directory not found: ${configDir}`);
|
|
290
292
|
}
|
|
291
|
-
if (
|
|
293
|
+
if (existing === null) {
|
|
292
294
|
throw new MissingHarnessError(`opencode config not found: ${filePath}`);
|
|
293
295
|
}
|
|
294
296
|
}
|
|
295
297
|
const config = existing ? parseJsonOrThrow(existing, filePath) : {};
|
|
296
298
|
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
|
297
|
-
|
|
298
|
-
mcp[resolved.serverName] = {
|
|
299
|
-
type: "local",
|
|
300
|
-
command: [...resolved.serverCommand],
|
|
301
|
-
enabled: true
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
delete mcp[resolved.serverName];
|
|
306
|
-
}
|
|
299
|
+
delete mcp[resolved.serverName];
|
|
307
300
|
config.mcp = mcp;
|
|
308
301
|
resolved.hooks.ensureDir(path.dirname(filePath));
|
|
309
302
|
resolved.hooks.writeFile(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
@@ -606,6 +599,22 @@ function successStatusForOperation(operation, beforeState) {
|
|
|
606
599
|
}
|
|
607
600
|
function formatMcpActionMessage(action, status, fallback) {
|
|
608
601
|
if (!action.serverName || !action.operation) {
|
|
602
|
+
if (action.kind === "file-patch") {
|
|
603
|
+
switch (status) {
|
|
604
|
+
case "added":
|
|
605
|
+
return `Installed ${action.filePath}.`;
|
|
606
|
+
case "updated":
|
|
607
|
+
return `Updated ${action.filePath}.`;
|
|
608
|
+
case "already_present":
|
|
609
|
+
return `${action.filePath} is already installed.`;
|
|
610
|
+
case "removed":
|
|
611
|
+
return `Removed ${action.filePath}.`;
|
|
612
|
+
case "already_absent":
|
|
613
|
+
return `${action.filePath} is already absent.`;
|
|
614
|
+
default:
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
609
618
|
return fallback ?? "ok";
|
|
610
619
|
}
|
|
611
620
|
const target = `MCP server '${action.serverName}'`;
|
package/dist/instructions.js
CHANGED
|
@@ -12,7 +12,7 @@ On freshly invoked multi-agent tasks, give peers a short window to join before d
|
|
|
12
12
|
|
|
13
13
|
Use phase names in handoffs when they clarify the work: draft, adversarial review, convergence, implementation, implementation review, test review, and release. These phases are vocabulary, not protocol state.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Claude and Codex are peers of comparable capability; neither outranks the other. Split work evenly between them rather than routing by stereotype, and have all models plan, implement, and evaluate together: any harness can draft, review, converge, implement, or release. Gemini and OpenCode start with conservative local guidance until project dogfood says otherwise.
|
|
16
16
|
|
|
17
17
|
For multi-agent design work, prefer independent read-only drafts first, then adversarial review and convergence. Do not impose a draft file structure on the workspace by default. If scratch draft files are useful, delete superseded pre-convergence drafts after the converged plan exists unless the operator asks to keep them.
|
|
18
18
|
|
|
@@ -20,11 +20,11 @@ Default to normal release handoffs. Use named assignment only when a specific me
|
|
|
20
20
|
|
|
21
21
|
## Claude
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Take a full, even share of planning, implementation, and evaluation. Watch for scope creep and messy first-pass artifacts. Make the next phase explicit in the handoff.
|
|
24
24
|
|
|
25
25
|
## Codex
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Take a full, even share of planning, implementation, and evaluation. Watch for over-indexing on mechanics when the operator still needs to decide direction. Make the next phase explicit in the handoff.
|
|
28
28
|
|
|
29
29
|
## Gemini
|
|
30
30
|
|
|
@@ -151,6 +151,10 @@ export function extractHarnessInstructions(markdown, harness) {
|
|
|
151
151
|
sections.get(current)?.push(line);
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
|
+
if (sawSection && isMarkdownH2Header(line)) {
|
|
155
|
+
current = null;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
154
158
|
if (!sawSection) {
|
|
155
159
|
shared.push(line);
|
|
156
160
|
continue;
|
|
@@ -202,6 +206,9 @@ function ensureInstructionFile(filePath) {
|
|
|
202
206
|
fs.writeFileSync(filePath, DEFAULT_INSTRUCTIONS_MARKDOWN);
|
|
203
207
|
return true;
|
|
204
208
|
}
|
|
209
|
+
function isMarkdownH2Header(line) {
|
|
210
|
+
return /^##\s+.+?\s*$/.test(line);
|
|
211
|
+
}
|
|
205
212
|
function parseHarnessHeader(line) {
|
|
206
213
|
const match = line.match(/^##\s+(.+?)\s*$/);
|
|
207
214
|
if (!match) {
|
package/dist/process-utils.js
CHANGED
|
@@ -56,7 +56,11 @@ function inspectSystemProcess(pid, options) {
|
|
|
56
56
|
try {
|
|
57
57
|
const output = (options.execFile ?? defaultExecFile)("ps", ["-o", "ppid=", "-o", "lstart=", "-o", "command=", "-p", String(pid)], {
|
|
58
58
|
encoding: "utf8",
|
|
59
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
59
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
LC_ALL: "C"
|
|
63
|
+
}
|
|
60
64
|
}).trimEnd();
|
|
61
65
|
if (!output.trim()) {
|
|
62
66
|
return null;
|
package/dist/service.js
CHANGED
|
@@ -1416,10 +1416,13 @@ export class TalkingStickService {
|
|
|
1416
1416
|
return null;
|
|
1417
1417
|
}
|
|
1418
1418
|
const lastOwnership = this.getLastOwnershipByAgent(roomId);
|
|
1419
|
-
const
|
|
1419
|
+
const ordinalRanks = ordinalRankByAgent(members);
|
|
1420
|
+
const referenceRank = afterAgentId
|
|
1421
|
+
? ordinalRanks.get(afterAgentId) ?? -1
|
|
1422
|
+
: -1;
|
|
1420
1423
|
return candidates
|
|
1421
1424
|
.slice()
|
|
1422
|
-
.sort((left, right) => compareFairCandidates(left, right, lastOwnership,
|
|
1425
|
+
.sort((left, right) => compareFairCandidates(left, right, lastOwnership, ordinalRanks, referenceRank, members.length))[0];
|
|
1423
1426
|
}
|
|
1424
1427
|
getLastOwnershipByAgent(roomId) {
|
|
1425
1428
|
const rows = this.db
|
|
@@ -1691,7 +1694,7 @@ export class TalkingStickService {
|
|
|
1691
1694
|
: "gone";
|
|
1692
1695
|
state =
|
|
1693
1696
|
this.hasExpired(room.claim_expires_at, now) &&
|
|
1694
|
-
reservedLiveness
|
|
1697
|
+
this.isGonePersistent(reservedMember, reservedLiveness, now)
|
|
1695
1698
|
? "recipient_gone"
|
|
1696
1699
|
: "reserved";
|
|
1697
1700
|
}
|
|
@@ -1741,7 +1744,7 @@ export class TalkingStickService {
|
|
|
1741
1744
|
: "gone";
|
|
1742
1745
|
state =
|
|
1743
1746
|
this.hasExpired(room.claim_expires_at, now) &&
|
|
1744
|
-
reservedLiveness
|
|
1747
|
+
this.isGonePersistent(reservedMember, reservedLiveness, now)
|
|
1745
1748
|
? "recipient_gone"
|
|
1746
1749
|
: "reserved";
|
|
1747
1750
|
}
|
|
@@ -1899,7 +1902,7 @@ function mapNoteRow(row) {
|
|
|
1899
1902
|
resolved_by_agent_id: row.resolved_by_agent_id
|
|
1900
1903
|
};
|
|
1901
1904
|
}
|
|
1902
|
-
function compareFairCandidates(left, right, lastOwnership,
|
|
1905
|
+
function compareFairCandidates(left, right, lastOwnership, ordinalRanks, referenceRank, memberCount) {
|
|
1903
1906
|
const leftLastOwned = lastOwnership.get(left.agent_id);
|
|
1904
1907
|
const rightLastOwned = lastOwnership.get(right.agent_id);
|
|
1905
1908
|
if (!leftLastOwned && rightLastOwned) {
|
|
@@ -1911,18 +1914,24 @@ function compareFairCandidates(left, right, lastOwnership, referenceOrdinal, mem
|
|
|
1911
1914
|
if (leftLastOwned && rightLastOwned && leftLastOwned !== rightLastOwned) {
|
|
1912
1915
|
return Date.parse(leftLastOwned) - Date.parse(rightLastOwned);
|
|
1913
1916
|
}
|
|
1914
|
-
const leftDistance = sequenceDistance(left.ordinal,
|
|
1915
|
-
const rightDistance = sequenceDistance(right.ordinal,
|
|
1917
|
+
const leftDistance = sequenceDistance(ordinalRanks.get(left.agent_id) ?? left.ordinal, referenceRank, memberCount);
|
|
1918
|
+
const rightDistance = sequenceDistance(ordinalRanks.get(right.agent_id) ?? right.ordinal, referenceRank, memberCount);
|
|
1916
1919
|
if (leftDistance !== rightDistance) {
|
|
1917
1920
|
return leftDistance - rightDistance;
|
|
1918
1921
|
}
|
|
1919
1922
|
return left.ordinal - right.ordinal;
|
|
1920
1923
|
}
|
|
1921
|
-
function
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
+
function ordinalRankByAgent(members) {
|
|
1925
|
+
return new Map(members
|
|
1926
|
+
.slice()
|
|
1927
|
+
.sort((left, right) => left.ordinal - right.ordinal)
|
|
1928
|
+
.map((member, index) => [member.agent_id, index]));
|
|
1929
|
+
}
|
|
1930
|
+
function sequenceDistance(ordinalRank, referenceRank, memberCount) {
|
|
1931
|
+
if (memberCount <= 0 || referenceRank < 0) {
|
|
1932
|
+
return ordinalRank;
|
|
1924
1933
|
}
|
|
1925
|
-
const distance = (
|
|
1934
|
+
const distance = (ordinalRank - referenceRank + memberCount) % memberCount;
|
|
1926
1935
|
return distance === 0 ? memberCount : distance;
|
|
1927
1936
|
}
|
|
1928
1937
|
function parseTimestampMs(timestamp) {
|
package/dist/session-store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
3
4
|
import { resolveDataDir } from "./config.js";
|
|
4
5
|
import { ancestorPaths, resolveContextPath } from "./path-resolution.js";
|
|
5
6
|
export function resolveCliSessionPath(options = {}) {
|
|
@@ -22,8 +23,7 @@ export function readCliSessions(sessionPath) {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
export function writeCliSessions(sessionPath, sessions) {
|
|
25
|
-
|
|
26
|
-
fs.writeFileSync(sessionPath, `${JSON.stringify(sessions, null, 2)}\n`);
|
|
26
|
+
writeFileAtomic(sessionPath, `${JSON.stringify(sessions, null, 2)}\n`);
|
|
27
27
|
}
|
|
28
28
|
export function upsertCliSession(sessionPath, session) {
|
|
29
29
|
const sessions = readCliSessions(sessionPath);
|
package/dist/skill-install.js
CHANGED
|
@@ -18,7 +18,7 @@ export function resolveSkillTargetPath(harness, options = {}) {
|
|
|
18
18
|
case "grok":
|
|
19
19
|
return path.join(resolveHarnessConfigDir("grok", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
|
|
20
20
|
case "opencode":
|
|
21
|
-
return path.join(
|
|
21
|
+
return path.join(resolveHarnessConfigDir("opencode", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
|
|
22
22
|
default:
|
|
23
23
|
throw new Error(`Unknown skill-install harness: ${harness}`);
|
|
24
24
|
}
|
|
@@ -35,14 +35,16 @@ export function planSkillInstall(harness, options = {}) {
|
|
|
35
35
|
harness,
|
|
36
36
|
command: "gemini",
|
|
37
37
|
args: ["skills", "link", sourcePath, "--scope", "user", "--consent"],
|
|
38
|
-
description: `gemini skills link ${sourcePath} --scope user --consent
|
|
38
|
+
description: `gemini skills link ${sourcePath} --scope user --consent`,
|
|
39
|
+
operation: "install"
|
|
39
40
|
}
|
|
40
41
|
: {
|
|
41
42
|
kind: "exec",
|
|
42
43
|
harness,
|
|
43
44
|
command: "gemini",
|
|
44
45
|
args: ["skills", "install", sourcePath, "--scope", "user", "--consent"],
|
|
45
|
-
description: `gemini skills install ${sourcePath} --scope user --consent
|
|
46
|
+
description: `gemini skills install ${sourcePath} --scope user --consent`,
|
|
47
|
+
operation: "install"
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
const targetPath = resolveSkillTargetPath(harness, options);
|
|
@@ -58,6 +60,8 @@ export function planSkillInstall(harness, options = {}) {
|
|
|
58
60
|
description: shouldLink
|
|
59
61
|
? `link ${sourcePath} -> ${targetPath}`
|
|
60
62
|
: `copy ${sourcePath} -> ${targetPath}`,
|
|
63
|
+
operation: "install",
|
|
64
|
+
inspect: () => inspectInstalledSkill(sourcePath, targetPath, shouldLink),
|
|
61
65
|
apply: () => installSkillDirectory(sourcePath, targetPath, harnessRootPath, shouldLink, options)
|
|
62
66
|
};
|
|
63
67
|
}
|
|
@@ -177,6 +181,31 @@ function syncInstalledFileSkill(harness, sourcePath, sourceDigest, options) {
|
|
|
177
181
|
};
|
|
178
182
|
}
|
|
179
183
|
}
|
|
184
|
+
function inspectInstalledSkill(sourcePath, targetPath, link) {
|
|
185
|
+
try {
|
|
186
|
+
const stat = fs.lstatSync(targetPath);
|
|
187
|
+
if (link) {
|
|
188
|
+
if (!stat.isSymbolicLink()) {
|
|
189
|
+
return "different";
|
|
190
|
+
}
|
|
191
|
+
const currentTarget = fs.readlinkSync(targetPath);
|
|
192
|
+
const resolvedCurrentTarget = path.resolve(path.dirname(targetPath), currentTarget);
|
|
193
|
+
return sameRealPath(resolvedCurrentTarget, sourcePath)
|
|
194
|
+
? "present"
|
|
195
|
+
: "different";
|
|
196
|
+
}
|
|
197
|
+
if (stat.isDirectory() && digestDirectory(targetPath) === digestDirectory(sourcePath)) {
|
|
198
|
+
return "present";
|
|
199
|
+
}
|
|
200
|
+
return "different";
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code === "ENOENT") {
|
|
204
|
+
return "absent";
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
180
209
|
function removeInstalledSkill(targetPath, harnessRootPath, options = {}) {
|
|
181
210
|
const pathExists = options.pathExists ?? fs.existsSync;
|
|
182
211
|
if (options.skipMissing && harnessRootPath && !pathExists(harnessRootPath)) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Talking Stick 0.4.11
|
|
2
|
+
|
|
3
|
+
Date: 2026-06-09
|
|
4
|
+
|
|
5
|
+
## Fixed
|
|
6
|
+
- **Guardian no longer leaks when readiness times out.** `spawnGuardian`'s readiness timeout now kills the detached child and clears its listeners before rejecting. Previously the orphaned guardian survived, wrote `READY` to a stream nobody read, and held the lease indefinitely with no recorded PID for `stopGuardian` to reach. (#31)
|
|
7
|
+
- **Fair-turn ordering survives member churn.** Round-robin distance is now computed from each member's rank within the current member list instead of raw join ordinals modulo member count, which inverted ordering once departures left sparse ordinals (e.g. `[0, 5, 7]`). (#32)
|
|
8
|
+
- **Reserved-member liveness gets the same gone grace as owners.** Both room-inspection paths now run the reserved branch through `isGonePersistent`, so a transient process-check misread right after claim expiry can no longer deny the rightful recipient its grant. (#33)
|
|
9
|
+
- **`cli-sessions.json` and harness config patches are written atomically.** Both now write to a temp sibling and `rename`, so a crash or full disk mid-write can no longer truncate the session store or a user-owned config such as `opencode.json`. (#34)
|
|
10
|
+
- **`ps` lstart parsing is locale-stable.** Process inspection invokes `ps` with `LC_ALL=C`, so non-C locales no longer silently degrade identity resolution and liveness to the weakest fallback. (#35)
|
|
11
|
+
- **Errors are machine-readable in JSON mode.** When `--json` is requested, plain CLI errors serialize as `{"error": "cli_error", "message": ...}` on stderr (matching `ProtocolError`'s shape) instead of bare text, keeping the non-zero exit code. (#36)
|
|
12
|
+
- **Boolean flags never consume positionals.** The CLI parser has a boolean-flag registry, retiring the `--json`-eats-positional footgun and the per-command repair shims; `--after`-style integer options now reject trailing garbage like `100ms`. (#37)
|
|
13
|
+
- **Non-harness `##` headings no longer bleed into harness sections.** In instruction files, an unrecognized `##` heading after harness sections ends the current section; its content is excluded from harness extraction. (#38)
|
|
14
|
+
- **OpenCode skill installs follow the XDG-aware config dir.** The skill now lands next to `opencode.json` (normally `~/.config/opencode/skills/talking-stick`, honoring `XDG_CONFIG_HOME`) instead of the hardcoded `~/.opencode` tree; verified against OpenCode source, which scans both `skill/` and `skills/` under the config dir. (#39)
|
|
15
|
+
- **Restarted Grok sessions mint fresh identity.** When PID identity is available but matches no recorded session, the workspace-candidate fallback no longer hands the new process the previous session's identity while the old `session_end` hook is pending. (#40)
|
|
16
|
+
- **`tt install` is idempotent for skills.** Skill install actions carry `operation`/`inspect`, so a second run reports `already_present` instead of deleting and re-copying the skill directory every time. (#41)
|
|
17
|
+
- **Docs drift from the MCP-to-skill migration cleaned up.** AGENTS.md/README no longer reference the removed `src/mcp-server.ts` entry point or stale install paths; `patchOpencodeConfig`'s dead install branch is gone and the legacy `tt mcp` command constant is marked as match-only. (#42)
|
|
18
|
+
|
|
19
|
+
## Verification
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm run typecheck
|
|
23
|
+
npm test
|
|
24
|
+
npm run build
|
|
25
|
+
node dist/cli.js --help
|
|
26
|
+
git diff --check
|
|
27
|
+
npm pack --dry-run
|
|
28
|
+
```
|