@ammduncan/easel 0.2.0
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/LICENCE +21 -0
- package/README.md +121 -0
- package/bin/easel +2 -0
- package/dist/cli.js +368 -0
- package/dist/client/index.css +441 -0
- package/dist/client/index.html +74 -0
- package/dist/client/index.js +236 -0
- package/dist/client/viewer.css +717 -0
- package/dist/client/viewer.html +100 -0
- package/dist/client/viewer.js +1215 -0
- package/dist/config-store.js +36 -0
- package/dist/http-entry.js +2 -0
- package/dist/http-server.js +202 -0
- package/dist/mcp.js +235 -0
- package/dist/paths.js +45 -0
- package/dist/server-manager.js +94 -0
- package/dist/session-id.js +78 -0
- package/dist/session-store.js +185 -0
- package/package.json +60 -0
- package/scripts/easel-session-id.mjs +140 -0
- package/skills/using-easel/SKILL.md +315 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { HOOK_DIR } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the session id that owns this MCP process. Tries, in order:
|
|
8
|
+
* 1. EASEL_SESSION_ID env var (explicit override for any client)
|
|
9
|
+
* 2. CLAUDE_CODE_SESSION_ID / CLAUDE_SESSION_ID (Claude Code)
|
|
10
|
+
* 3. Hook file at ~/.easel/hook/cc-session-<ppid>.txt (Claude Code's
|
|
11
|
+
* SessionStart hook writes this; pitstop-style PPID bridging)
|
|
12
|
+
* 4. Most-recently-modified transcript under ~/.claude/projects/<cwd>/
|
|
13
|
+
* (Claude Code transcript scan)
|
|
14
|
+
* 5. Synthetic id derived from this MCP child's PPID — gives every other
|
|
15
|
+
* MCP client (Cursor, Windsurf, Claude Desktop, etc.) a stable session
|
|
16
|
+
* per chat without requiring any hook. The MCP child IS the session.
|
|
17
|
+
*
|
|
18
|
+
* Always returns a value from tier 5 if all higher tiers miss, so non-CC
|
|
19
|
+
* clients are usable out of the box.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveClaudeSessionId(opts = {}) {
|
|
22
|
+
const home = opts.homeDir ?? homedir();
|
|
23
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
24
|
+
const ppid = opts.ppid ?? process.ppid;
|
|
25
|
+
const env = opts.env ?? process.env;
|
|
26
|
+
const explicit = env.EASEL_SESSION_ID;
|
|
27
|
+
if (explicit)
|
|
28
|
+
return explicit;
|
|
29
|
+
const fromCcEnv = env.CLAUDE_CODE_SESSION_ID ?? env.CLAUDE_SESSION_ID;
|
|
30
|
+
if (fromCcEnv)
|
|
31
|
+
return fromCcEnv;
|
|
32
|
+
const hookFile = join(HOOK_DIR, `cc-session-${ppid}.txt`);
|
|
33
|
+
try {
|
|
34
|
+
const id = readFileSync(hookFile, "utf-8").trim();
|
|
35
|
+
if (id)
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* fall through */
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const encoded = cwd.replace(/\//g, "-");
|
|
43
|
+
const dir = join(home, ".claude", "projects", encoded);
|
|
44
|
+
let bestId;
|
|
45
|
+
let bestMtime = 0;
|
|
46
|
+
for (const f of readdirSync(dir)) {
|
|
47
|
+
if (!f.endsWith(".jsonl"))
|
|
48
|
+
continue;
|
|
49
|
+
const m = statSync(join(dir, f)).mtimeMs;
|
|
50
|
+
if (m > bestMtime) {
|
|
51
|
+
bestMtime = m;
|
|
52
|
+
bestId = f.slice(0, -".jsonl".length);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (bestId)
|
|
56
|
+
return bestId;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* fall through */
|
|
60
|
+
}
|
|
61
|
+
return syntheticSessionIdFromPpid(ppid);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Mints a stable, UUID-shaped id from the parent PID and parent boot time.
|
|
65
|
+
* Same PPID across an MCP-child restart → same id (so a flaky child doesn't
|
|
66
|
+
* spawn a new tab); different chat / different client process → different id.
|
|
67
|
+
*/
|
|
68
|
+
function syntheticSessionIdFromPpid(ppid) {
|
|
69
|
+
const seed = `mcp-ppid-${ppid}`;
|
|
70
|
+
const hex = createHash("sha1").update(seed).digest("hex");
|
|
71
|
+
return [
|
|
72
|
+
hex.slice(0, 8),
|
|
73
|
+
hex.slice(8, 12),
|
|
74
|
+
hex.slice(12, 16),
|
|
75
|
+
hex.slice(16, 20),
|
|
76
|
+
hex.slice(20, 32),
|
|
77
|
+
].join("-");
|
|
78
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { MAX_PUSHES_PER_SESSION, SESSIONS_DIR, SESSION_IDLE_TTL_MS, } from "./paths.js";
|
|
5
|
+
const META_FILE = "meta.json";
|
|
6
|
+
const PUSHES_DIR = "pushes";
|
|
7
|
+
function sessionDir(id) {
|
|
8
|
+
return join(SESSIONS_DIR, id);
|
|
9
|
+
}
|
|
10
|
+
function metaPath(id) {
|
|
11
|
+
return join(sessionDir(id), META_FILE);
|
|
12
|
+
}
|
|
13
|
+
function pushesDir(id) {
|
|
14
|
+
return join(sessionDir(id), PUSHES_DIR);
|
|
15
|
+
}
|
|
16
|
+
function ensureSession(id) {
|
|
17
|
+
const dir = sessionDir(id);
|
|
18
|
+
if (!existsSync(dir)) {
|
|
19
|
+
mkdirSync(pushesDir(id), { recursive: true });
|
|
20
|
+
const meta = {
|
|
21
|
+
id,
|
|
22
|
+
createdAt: Date.now(),
|
|
23
|
+
lastActivity: Date.now(),
|
|
24
|
+
nextIndex: 1,
|
|
25
|
+
prunedCount: 0,
|
|
26
|
+
cwd: null,
|
|
27
|
+
label: null,
|
|
28
|
+
};
|
|
29
|
+
writeFileSync(metaPath(id), JSON.stringify(meta, null, 2));
|
|
30
|
+
return meta;
|
|
31
|
+
}
|
|
32
|
+
const existing = readMeta(id);
|
|
33
|
+
// Backfill new fields on older sessions.
|
|
34
|
+
if (existing.cwd === undefined)
|
|
35
|
+
existing.cwd = null;
|
|
36
|
+
if (existing.label === undefined)
|
|
37
|
+
existing.label = null;
|
|
38
|
+
return existing;
|
|
39
|
+
}
|
|
40
|
+
function readMeta(id) {
|
|
41
|
+
return JSON.parse(readFileSync(metaPath(id), "utf-8"));
|
|
42
|
+
}
|
|
43
|
+
function writeMeta(meta) {
|
|
44
|
+
writeFileSync(metaPath(meta.id), JSON.stringify(meta, null, 2));
|
|
45
|
+
}
|
|
46
|
+
export function touchSession(id) {
|
|
47
|
+
const meta = ensureSession(id);
|
|
48
|
+
meta.lastActivity = Date.now();
|
|
49
|
+
writeMeta(meta);
|
|
50
|
+
return meta;
|
|
51
|
+
}
|
|
52
|
+
export function listPushes(id) {
|
|
53
|
+
if (!existsSync(pushesDir(id)))
|
|
54
|
+
return [];
|
|
55
|
+
const files = readdirSync(pushesDir(id))
|
|
56
|
+
.filter((f) => f.endsWith(".json"))
|
|
57
|
+
.sort();
|
|
58
|
+
return files.map((f) => JSON.parse(readFileSync(join(pushesDir(id), f), "utf-8")));
|
|
59
|
+
}
|
|
60
|
+
export function getSessionView(id) {
|
|
61
|
+
const meta = ensureSession(id);
|
|
62
|
+
return { meta, pushes: listPushes(id) };
|
|
63
|
+
}
|
|
64
|
+
export function appendPush(sessionId, input) {
|
|
65
|
+
const meta = ensureSession(sessionId);
|
|
66
|
+
const push = {
|
|
67
|
+
id: randomUUID(),
|
|
68
|
+
index: meta.nextIndex,
|
|
69
|
+
title: input.title?.trim() || null,
|
|
70
|
+
kind: input.kind?.trim() || null,
|
|
71
|
+
html: input.html,
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
const filename = `${String(push.index).padStart(6, "0")}-${push.id}.json`;
|
|
75
|
+
writeFileSync(join(pushesDir(sessionId), filename), JSON.stringify(push));
|
|
76
|
+
meta.nextIndex += 1;
|
|
77
|
+
meta.lastActivity = push.createdAt;
|
|
78
|
+
pruneOldPushes(sessionId, meta);
|
|
79
|
+
writeMeta(meta);
|
|
80
|
+
return push;
|
|
81
|
+
}
|
|
82
|
+
function pruneOldPushes(sessionId, meta) {
|
|
83
|
+
const files = readdirSync(pushesDir(sessionId))
|
|
84
|
+
.filter((f) => f.endsWith(".json"))
|
|
85
|
+
.sort();
|
|
86
|
+
while (files.length > MAX_PUSHES_PER_SESSION) {
|
|
87
|
+
const victim = files.shift();
|
|
88
|
+
if (!victim)
|
|
89
|
+
break;
|
|
90
|
+
rmSync(join(pushesDir(sessionId), victim));
|
|
91
|
+
meta.prunedCount += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Delete sessions idle longer than SESSION_IDLE_TTL_MS. Cheap; called from
|
|
96
|
+
* every push and once at HTTP-server boot.
|
|
97
|
+
*/
|
|
98
|
+
export function sweepIdleSessions(now = Date.now()) {
|
|
99
|
+
const removed = [];
|
|
100
|
+
if (!existsSync(SESSIONS_DIR))
|
|
101
|
+
return { removed };
|
|
102
|
+
for (const id of readdirSync(SESSIONS_DIR)) {
|
|
103
|
+
const meta = safeReadMeta(id);
|
|
104
|
+
if (!meta)
|
|
105
|
+
continue;
|
|
106
|
+
if (now - meta.lastActivity > SESSION_IDLE_TTL_MS) {
|
|
107
|
+
rmSync(sessionDir(id), { recursive: true, force: true });
|
|
108
|
+
removed.push(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { removed };
|
|
112
|
+
}
|
|
113
|
+
function safeReadMeta(id) {
|
|
114
|
+
try {
|
|
115
|
+
return readMeta(id);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function sessionExists(id) {
|
|
122
|
+
return existsSync(metaPath(id));
|
|
123
|
+
}
|
|
124
|
+
/** Remove a single push from a session. Returns true if it existed. */
|
|
125
|
+
export function deletePush(sessionId, pushId) {
|
|
126
|
+
const dir = pushesDir(sessionId);
|
|
127
|
+
if (!existsSync(dir))
|
|
128
|
+
return false;
|
|
129
|
+
for (const f of readdirSync(dir)) {
|
|
130
|
+
if (f.endsWith(`-${pushId}.json`)) {
|
|
131
|
+
rmSync(join(dir, f));
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
/** Delete an entire session from disk. */
|
|
138
|
+
export function deleteSession(id) {
|
|
139
|
+
const dir = sessionDir(id);
|
|
140
|
+
if (!existsSync(dir))
|
|
141
|
+
return false;
|
|
142
|
+
rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/** First-touch from CLI: ensure session exists without bumping activity. */
|
|
146
|
+
export function registerSession(id) {
|
|
147
|
+
return ensureSession(id);
|
|
148
|
+
}
|
|
149
|
+
export function updateSessionMeta(id, patch) {
|
|
150
|
+
const meta = ensureSession(id);
|
|
151
|
+
if (patch.cwd !== undefined)
|
|
152
|
+
meta.cwd = patch.cwd;
|
|
153
|
+
if (patch.label !== undefined)
|
|
154
|
+
meta.label = patch.label;
|
|
155
|
+
writeMeta(meta);
|
|
156
|
+
return meta;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* List every session with derived fields suitable for the index page —
|
|
160
|
+
* push count, latest push summary, and the meta. Sorted by lastActivity desc.
|
|
161
|
+
*/
|
|
162
|
+
export function listSessionSummaries() {
|
|
163
|
+
if (!existsSync(SESSIONS_DIR))
|
|
164
|
+
return [];
|
|
165
|
+
const summaries = [];
|
|
166
|
+
for (const id of readdirSync(SESSIONS_DIR)) {
|
|
167
|
+
try {
|
|
168
|
+
const meta = ensureSession(id);
|
|
169
|
+
const pushes = listPushes(id);
|
|
170
|
+
const last = pushes[pushes.length - 1];
|
|
171
|
+
summaries.push({
|
|
172
|
+
...meta,
|
|
173
|
+
pushCount: pushes.length,
|
|
174
|
+
lastPushTitle: last?.title ?? null,
|
|
175
|
+
lastPushKind: last?.kind ?? null,
|
|
176
|
+
lastPushAt: last?.createdAt ?? null,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* skip corrupt session dir */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
summaries.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
184
|
+
return summaries;
|
|
185
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ammduncan/easel",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ammiel Yawson",
|
|
8
|
+
"homepage": "https://github.com/AmmDuncan/easel#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/AmmDuncan/easel.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/AmmDuncan/easel/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"cursor",
|
|
21
|
+
"windsurf",
|
|
22
|
+
"claude-desktop",
|
|
23
|
+
"agent-ui",
|
|
24
|
+
"browser-display",
|
|
25
|
+
"live-feed"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"easel": "bin/easel"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/",
|
|
35
|
+
"dist/",
|
|
36
|
+
"scripts/easel-session-id.mjs",
|
|
37
|
+
"skills/",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENCE"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc && node scripts/copy-client.mjs",
|
|
43
|
+
"start": "node dist/cli.js",
|
|
44
|
+
"dev": "tsc --watch",
|
|
45
|
+
"prepublishOnly": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
52
|
+
"express": "^4.21.2",
|
|
53
|
+
"zod": "^3.24.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/express": "^5.0.0",
|
|
57
|
+
"@types/node": "^22.10.5",
|
|
58
|
+
"typescript": "^5.7.2"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// easel-session-id.mjs — SessionStart hook for Claude Code.
|
|
3
|
+
//
|
|
4
|
+
// Does three things:
|
|
5
|
+
// 1. Captures Claude's session_id (passed as JSON on stdin per the hooks
|
|
6
|
+
// spec) into ~/.easel/hook/cc-session-<ppid>.txt so the easel MCP
|
|
7
|
+
// adapter can resolve it by parent PID.
|
|
8
|
+
// 2. Outputs `additionalContext` JSON so the agent always sees the easel
|
|
9
|
+
// conventions at the start of every chat — even when the chat never
|
|
10
|
+
// triggers the using-easel skill via content shape.
|
|
11
|
+
// 3. Checks (cached, every 24h) whether the local install is behind
|
|
12
|
+
// origin/main and appends an "updates available" line.
|
|
13
|
+
//
|
|
14
|
+
// Ported from bash to drop the jq dependency.
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
const HOME = homedir();
|
|
23
|
+
const LEGACY_ROOT = join(HOME, ".claude-display");
|
|
24
|
+
const DATA_ROOT = join(HOME, ".easel");
|
|
25
|
+
|
|
26
|
+
// One-time migration from the project's prior name. Idempotent.
|
|
27
|
+
if (existsSync(LEGACY_ROOT) && !existsSync(DATA_ROOT)) {
|
|
28
|
+
try {
|
|
29
|
+
renameSync(LEGACY_ROOT, DATA_ROOT);
|
|
30
|
+
} catch {
|
|
31
|
+
// best-effort; downstream mkdir handles it
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readStdinSync() {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(0, "utf-8");
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSessionId(raw) {
|
|
44
|
+
try {
|
|
45
|
+
const obj = JSON.parse(raw);
|
|
46
|
+
return typeof obj?.session_id === "string" ? obj.session_id : "";
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const input = readStdinSync();
|
|
53
|
+
const sessionId = parseSessionId(input);
|
|
54
|
+
|
|
55
|
+
if (sessionId) {
|
|
56
|
+
const hookDir = join(DATA_ROOT, "hook");
|
|
57
|
+
mkdirSync(hookDir, { recursive: true });
|
|
58
|
+
writeFileSync(join(hookDir, `cc-session-${process.ppid}.txt`), `${sessionId}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- staleness check (cached, every 24h) ------------------------------------
|
|
62
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
const INSTALL_DIR = resolve(__dirname, "..");
|
|
64
|
+
const CACHE_FILE = join(DATA_ROOT, "update-check");
|
|
65
|
+
const NOW = Math.floor(Date.now() / 1000);
|
|
66
|
+
|
|
67
|
+
function git(args, opts = {}) {
|
|
68
|
+
const r = spawnSync("git", ["-C", INSTALL_DIR, ...args], {
|
|
69
|
+
encoding: "utf-8",
|
|
70
|
+
timeout: opts.timeoutMs ?? 800,
|
|
71
|
+
});
|
|
72
|
+
if (r.status !== 0) return "";
|
|
73
|
+
return (r.stdout ?? "").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let localSha = "";
|
|
77
|
+
let remoteSha = "";
|
|
78
|
+
if (existsSync(join(INSTALL_DIR, ".git"))) {
|
|
79
|
+
localSha = git(["rev-parse", "HEAD"]);
|
|
80
|
+
|
|
81
|
+
let lastCheck = 0;
|
|
82
|
+
let lastRemote = "";
|
|
83
|
+
if (existsSync(CACHE_FILE)) {
|
|
84
|
+
try {
|
|
85
|
+
const [line1 = "", line2 = ""] = readFileSync(CACHE_FILE, "utf-8").split("\n");
|
|
86
|
+
lastCheck = Number.parseInt(line1, 10) || 0;
|
|
87
|
+
lastRemote = line2.trim();
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore — cache miss is fine
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (NOW - lastCheck > 86400) {
|
|
94
|
+
const lsRemote = git(["ls-remote", "origin", "main"], { timeoutMs: 2000 });
|
|
95
|
+
remoteSha = lsRemote.split(/\s+/)[0] ?? "";
|
|
96
|
+
if (remoteSha) {
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(DATA_ROOT, { recursive: true });
|
|
99
|
+
writeFileSync(CACHE_FILE, `${NOW}\n${remoteSha}\n`);
|
|
100
|
+
} catch {
|
|
101
|
+
// best-effort cache write
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
remoteSha = lastRemote;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const updateLine =
|
|
110
|
+
localSha && remoteSha && localSha !== remoteSha
|
|
111
|
+
? "\n\nUPDATE AVAILABLE — local easel is behind origin/main. Run `easel update` to pull, build, and re-wire setup. Mention this to the user once if they bring up easel; otherwise it's just FYI."
|
|
112
|
+
: "";
|
|
113
|
+
|
|
114
|
+
// --- additionalContext reminder ---------------------------------------------
|
|
115
|
+
const reminder = `easel is registered as an MCP for this session. Tools available:
|
|
116
|
+
mcp__easel__push, mcp__easel__open, mcp__easel__config, mcp__easel__label.
|
|
117
|
+
|
|
118
|
+
CONVENTION — label every session early.
|
|
119
|
+
Call mcp__easel__label({ label: "<short human name>" }) as soon as the
|
|
120
|
+
user's intent is clear in this chat, and NO LATER than your first
|
|
121
|
+
mcp__easel__push call. Sessions without labels show up as the cwd
|
|
122
|
+
basename in the switcher (e.g. "dvla"), which is unfindable when
|
|
123
|
+
multiple tabs are open. Re-call label when the work's theme shifts
|
|
124
|
+
meaningfully. Format: 1–8 words, sentence case, no trailing punctuation,
|
|
125
|
+
mention the artefact not the verb (good: "RegistrationNumberInput
|
|
126
|
+
extraction"; bad: "Extracting RegistrationNumberInput").
|
|
127
|
+
|
|
128
|
+
When pushing visual content (mockup, diagram, comparison, long
|
|
129
|
+
explanation, diff, multi-step status), use mcp__easel__push proactively
|
|
130
|
+
— do not ask permission. Reply in chat with one line: "pushed to easel
|
|
131
|
+
↗ — #N". The full style guide lives in the using-easel skill.${updateLine}`;
|
|
132
|
+
|
|
133
|
+
process.stdout.write(
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
hookSpecificOutput: {
|
|
136
|
+
hookEventName: "SessionStart",
|
|
137
|
+
additionalContext: reminder,
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|