codehere 0.5.0-alpha.4 → 0.5.0-alpha.6
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/dist/application/use-cases/generate-final-report.js +22 -6
- package/dist/application/use-cases/generate-thread-title.js +120 -0
- package/dist/index.js +18 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +59 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +76 -0
- package/dist/infrastructure/orchestration/conductor-adapter.js +11 -1
- package/dist/infrastructure/orchestration/gemini-adapter.js +58 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +62 -0
- package/dist/infrastructure/orchestration/session-manager.js +42 -1
- package/dist/infrastructure/orchestration/session-persistence.js +28 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +129 -4
- package/dist/infrastructure/protocols/ui-page.js +616 -17
- package/dist/infrastructure/storage/cross-tool-agents.js +251 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +192 -0
- package/dist/infrastructure/storage/project-thread-reader.js +140 -0
- package/dist/infrastructure/storage/project-thread-title-backfill.js +182 -0
- package/package.json +4 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-tool agent definition reader — v0.6.0 M8.
|
|
3
|
+
*
|
|
4
|
+
* Reads agent definitions written for OTHER tools (OpenCode, Claude Code) and
|
|
5
|
+
* normalizes them into codehere's internal shape so they appear in the agents
|
|
6
|
+
* pane alongside codehere's built-in adapters.
|
|
7
|
+
*
|
|
8
|
+
* Built from Spike-4 prototype (agent/spike/v0.6.0-cross-tool-parser.mjs);
|
|
9
|
+
* production version uses the `yaml` lib for proper 2-level nesting + multi-
|
|
10
|
+
* line scalars + quoted-string handling, recurses subdirectories per the
|
|
11
|
+
* AGENTS.md hierarchical convention, skips files without frontmatter, and
|
|
12
|
+
* surfaces unknown frontmatter keys as warnings (not silent drops).
|
|
13
|
+
*
|
|
14
|
+
* Sources read in precedence order (later overrides earlier on name conflict):
|
|
15
|
+
* 1. ~/.claude/agents/ (global Claude Code subagents)
|
|
16
|
+
* 2. ~/.opencode/agents/ (global OpenCode agents)
|
|
17
|
+
* 3. ~/.codehere/agents/ (global codehere agents)
|
|
18
|
+
* 4. <cwd>/.claude/agents/ (project Claude Code)
|
|
19
|
+
* 5. <cwd>/.opencode/agents/ (project OpenCode)
|
|
20
|
+
* 6. <cwd>/.codehere/agents/ (project codehere)
|
|
21
|
+
*
|
|
22
|
+
* Format normalization handles divergences:
|
|
23
|
+
* - Claude `name` field vs OpenCode (filename = name)
|
|
24
|
+
* - Claude `tools: "A, B, C"` (string) vs OpenCode `tools: {a: true, b: false}` (object)
|
|
25
|
+
* - Claude no `mode` (subagent implicit) vs OpenCode `mode: primary|subagent|all`
|
|
26
|
+
* - OpenCode `permission` block vs Claude (no permission block)
|
|
27
|
+
*
|
|
28
|
+
* NOT in scope of this module:
|
|
29
|
+
* - Permission ENFORCEMENT (this just READS them for display + future M5)
|
|
30
|
+
* - Template variable substitution ({{PROJECT_NAME}} stays as raw text)
|
|
31
|
+
* - Authoring (`codehere agent create` is C6, deferred)
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
34
|
+
import { join } from 'path';
|
|
35
|
+
import { homedir } from 'os';
|
|
36
|
+
import YAML from 'yaml';
|
|
37
|
+
/**
|
|
38
|
+
* Pre-process Claude Code-style frontmatter that contains unquoted colons in
|
|
39
|
+
* description/prompt values (e.g. "description: ... Examples:\n<example>\nContext: ...").
|
|
40
|
+
* Strict YAML parsers reject these; Claude Code itself uses a more permissive
|
|
41
|
+
* reader. We force-quote affected values before parsing.
|
|
42
|
+
*
|
|
43
|
+
* Heuristic: any top-level `description:` or `prompt:` line whose value
|
|
44
|
+
* contains a `: ` (colon-space) AND isn't already quoted/block-scalar gets
|
|
45
|
+
* single-quoted with internal single quotes doubled (YAML escape).
|
|
46
|
+
*/
|
|
47
|
+
function preprocessFrontmatter(fmText) {
|
|
48
|
+
return fmText
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((line) => {
|
|
51
|
+
const m = line.match(/^(description|prompt):\s*(.+)$/);
|
|
52
|
+
if (!m)
|
|
53
|
+
return line;
|
|
54
|
+
const [, key, value] = m;
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
57
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
58
|
+
trimmed.startsWith('|') ||
|
|
59
|
+
trimmed.startsWith('>')) {
|
|
60
|
+
return line; // already a safe scalar form
|
|
61
|
+
}
|
|
62
|
+
if (!/:\s/.test(trimmed))
|
|
63
|
+
return line; // no problematic embedded colons
|
|
64
|
+
const escaped = trimmed.replace(/'/g, "''");
|
|
65
|
+
return `${key}: '${escaped}'`;
|
|
66
|
+
})
|
|
67
|
+
.join('\n');
|
|
68
|
+
}
|
|
69
|
+
/** Recursively walk a dir, return all .md files (sorted for determinism). */
|
|
70
|
+
function listMdFilesRecursive(dir) {
|
|
71
|
+
if (!existsSync(dir))
|
|
72
|
+
return [];
|
|
73
|
+
let stat;
|
|
74
|
+
try {
|
|
75
|
+
stat = statSync(dir);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
if (!stat.isDirectory())
|
|
81
|
+
return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = readdirSync(dir);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries.sort()) {
|
|
91
|
+
if (entry.startsWith('.'))
|
|
92
|
+
continue;
|
|
93
|
+
const p = join(dir, entry);
|
|
94
|
+
let s;
|
|
95
|
+
try {
|
|
96
|
+
s = statSync(p);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (s.isDirectory()) {
|
|
102
|
+
out.push(...listMdFilesRecursive(p));
|
|
103
|
+
}
|
|
104
|
+
else if (s.isFile() && entry.endsWith('.md') && entry !== 'README.md') {
|
|
105
|
+
out.push(p);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Parse a single agent file. Returns null if no frontmatter or parse fails. */
|
|
111
|
+
export function parseAgentFile(filePath, defaultFormat = 'unknown') {
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(filePath, 'utf-8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (!text.startsWith('---'))
|
|
120
|
+
return null; // no frontmatter, not an agent file
|
|
121
|
+
const closeIdx = text.indexOf('\n---', 4);
|
|
122
|
+
if (closeIdx === -1)
|
|
123
|
+
return null;
|
|
124
|
+
const fmText = text.slice(4, closeIdx).trim();
|
|
125
|
+
const body = text.slice(closeIdx + 4).trim();
|
|
126
|
+
let fm;
|
|
127
|
+
try {
|
|
128
|
+
fm = YAML.parse(preprocessFrontmatter(fmText));
|
|
129
|
+
if (!fm || typeof fm !== 'object')
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
// Source format inference: presence of mode/permission/object-tools = OpenCode;
|
|
136
|
+
// presence of name + string-tools = Claude. Fall back to default by location.
|
|
137
|
+
const hasObjectTools = typeof fm.tools === 'object' && fm.tools !== null && !Array.isArray(fm.tools);
|
|
138
|
+
const sourceFormat = fm.mode || fm.permission || hasObjectTools
|
|
139
|
+
? 'opencode'
|
|
140
|
+
: fm.name || typeof fm.tools === 'string'
|
|
141
|
+
? 'claude'
|
|
142
|
+
: defaultFormat;
|
|
143
|
+
// Tools normalization
|
|
144
|
+
let tools = null;
|
|
145
|
+
if (typeof fm.tools === 'string') {
|
|
146
|
+
tools = fm.tools.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
else if (hasObjectTools) {
|
|
149
|
+
tools = [];
|
|
150
|
+
for (const [k, v] of Object.entries(fm.tools)) {
|
|
151
|
+
if (v === true)
|
|
152
|
+
tools.push(k.toLowerCase());
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Filename without .md = fallback name
|
|
156
|
+
const filenameName = filePath.split('/').pop()?.replace(/\.md$/, '') ?? 'unnamed';
|
|
157
|
+
// Mode normalization
|
|
158
|
+
const modeRaw = typeof fm.mode === 'string' ? fm.mode : null;
|
|
159
|
+
const mode = modeRaw === 'primary' || modeRaw === 'subagent' || modeRaw === 'all'
|
|
160
|
+
? modeRaw
|
|
161
|
+
: sourceFormat === 'claude'
|
|
162
|
+
? 'subagent'
|
|
163
|
+
: 'all';
|
|
164
|
+
// Track unknown frontmatter keys (everything outside our normalized set)
|
|
165
|
+
const knownKeys = new Set(['name', 'description', 'mode', 'model', 'temperature', 'tools', 'permission', 'color', 'hidden', 'top_p', 'steps', 'prompt', 'disable']);
|
|
166
|
+
const unknownKeys = Object.keys(fm).filter((k) => !knownKeys.has(k));
|
|
167
|
+
return {
|
|
168
|
+
name: typeof fm.name === 'string' ? fm.name : filenameName,
|
|
169
|
+
description: typeof fm.description === 'string' ? fm.description : '(no description)',
|
|
170
|
+
sourceFormat,
|
|
171
|
+
sourcePath: filePath,
|
|
172
|
+
mode,
|
|
173
|
+
model: typeof fm.model === 'string' ? fm.model : null,
|
|
174
|
+
temperature: typeof fm.temperature === 'number' ? fm.temperature : null,
|
|
175
|
+
tools,
|
|
176
|
+
permission: typeof fm.permission === 'object' && fm.permission !== null ? fm.permission : null,
|
|
177
|
+
color: typeof fm.color === 'string' ? fm.color : null,
|
|
178
|
+
hidden: fm.hidden === true,
|
|
179
|
+
body,
|
|
180
|
+
unknownKeys,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** All locations to scan, in precedence order (later overrides earlier on name conflict).
|
|
184
|
+
* Dedupes the dir list — when cwd == homeDir, project + global paths collapse and we
|
|
185
|
+
* shouldn't double-count files. Test isolation also relies on this (homeDir override
|
|
186
|
+
* to a project root). */
|
|
187
|
+
function agentDirsToScan(cwd, homeOverride) {
|
|
188
|
+
const home = homeOverride ?? homedir();
|
|
189
|
+
const dirs = [
|
|
190
|
+
{ dir: join(home, '.claude/agents'), defaultFormat: 'claude' },
|
|
191
|
+
{ dir: join(home, '.opencode/agents'), defaultFormat: 'opencode' },
|
|
192
|
+
{ dir: join(home, '.codehere/agents'), defaultFormat: 'codehere' },
|
|
193
|
+
];
|
|
194
|
+
if (cwd) {
|
|
195
|
+
dirs.push({ dir: join(cwd, '.claude/agents'), defaultFormat: 'claude' });
|
|
196
|
+
dirs.push({ dir: join(cwd, '.opencode/agents'), defaultFormat: 'opencode' });
|
|
197
|
+
dirs.push({ dir: join(cwd, '.codehere/agents'), defaultFormat: 'codehere' });
|
|
198
|
+
}
|
|
199
|
+
// Dedupe by dir path (preserves order — first occurrence wins)
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
return dirs.filter((d) => {
|
|
202
|
+
if (seen.has(d.dir))
|
|
203
|
+
return false;
|
|
204
|
+
seen.add(d.dir);
|
|
205
|
+
return true;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Read all cross-tool agent definitions from global + project dirs. Idempotent + cheap (~10ms for ~20 files); safe to call on every readiness query.
|
|
209
|
+
*
|
|
210
|
+
* @param cwd Project working directory whose .claude/.opencode/.codehere agents subdirs to scan
|
|
211
|
+
* @param options.homeDir Override home directory (testing only — production uses homedir())
|
|
212
|
+
*/
|
|
213
|
+
export function readCrossToolAgents(cwd, options) {
|
|
214
|
+
const byName = new Map();
|
|
215
|
+
let filesParsed = 0;
|
|
216
|
+
let filesSkippedNoFrontmatter = 0;
|
|
217
|
+
let filesSkippedParseError = 0;
|
|
218
|
+
let nameCollisions = 0;
|
|
219
|
+
const dirs = agentDirsToScan(cwd, options?.homeDir);
|
|
220
|
+
for (const { dir, defaultFormat } of dirs) {
|
|
221
|
+
for (const filePath of listMdFilesRecursive(dir)) {
|
|
222
|
+
let agent = null;
|
|
223
|
+
try {
|
|
224
|
+
agent = parseAgentFile(filePath, defaultFormat);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
filesSkippedParseError++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!agent) {
|
|
231
|
+
filesSkippedNoFrontmatter++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
filesParsed++;
|
|
235
|
+
if (byName.has(agent.name))
|
|
236
|
+
nameCollisions++;
|
|
237
|
+
byName.set(agent.name, agent); // later wins
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
agents: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
242
|
+
diagnostics: {
|
|
243
|
+
sourcesScanned: dirs.length,
|
|
244
|
+
filesParsed,
|
|
245
|
+
filesSkippedNoFrontmatter,
|
|
246
|
+
filesSkippedParseError,
|
|
247
|
+
nameCollisions,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=cross-tool-agents.js.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project thread forward-mirror — writes new sessions to per-project
|
|
3
|
+
* thread.jsonl alongside sessions.jsonl during normal daemon operation.
|
|
4
|
+
*
|
|
5
|
+
* v0.6.0 M1+M7 — companion to project-thread-migration.ts:
|
|
6
|
+
* - Migration handles HISTORICAL data (sessions.jsonl → projects/<slug>/thread.jsonl)
|
|
7
|
+
* - This handles NEW data (every spawn + completion writes to BOTH)
|
|
8
|
+
*
|
|
9
|
+
* Without this, the project threads created by migration are a one-time
|
|
10
|
+
* snapshot. With this, every new session lands in both formats and the
|
|
11
|
+
* project thread stays current as users work. UI restructure (next milestone)
|
|
12
|
+
* then renders thread.jsonl as the primary view.
|
|
13
|
+
*
|
|
14
|
+
* Write pattern matches session-persistence.ts:
|
|
15
|
+
* - Append-only (no rewrites; lock-free single writer)
|
|
16
|
+
* - Best-effort (failures don't break the spawn path)
|
|
17
|
+
* - Each session writes TWO entries over its lifetime:
|
|
18
|
+
* 1. On spawn: { type: 'task', sessionId, agent, prompt, cwd, startedAt, status: 'running' }
|
|
19
|
+
* 2. On complete: { type: 'task', sessionId, status, durationMs, completedAt, steps, artifacts }
|
|
20
|
+
* - Reader reconstructs by sessionId (later entry overrides earlier) — same
|
|
21
|
+
* shape as project-thread-migration.ts output.
|
|
22
|
+
*
|
|
23
|
+
* Filtering matches migration defaults:
|
|
24
|
+
* - Temp dirs (/tmp, /var/folders, /private/var/folders, /private/tmp) skipped
|
|
25
|
+
* - NULL cwd → 'unknown' bucket
|
|
26
|
+
*
|
|
27
|
+
* Iter 13 — built after v0.5.0-alpha.4 ship. Previously the only way new
|
|
28
|
+
* sessions reached thread.jsonl was to re-run migration; this closes the gap.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
31
|
+
import { homedir } from 'os';
|
|
32
|
+
import { join } from 'path';
|
|
33
|
+
import { createHash } from 'crypto';
|
|
34
|
+
const PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
|
|
35
|
+
/** Match migration module's defaults. Same regex set kept in sync. */
|
|
36
|
+
const TEMP_DIR_PATTERNS = [
|
|
37
|
+
/^\/tmp\//,
|
|
38
|
+
/^\/var\/folders\//,
|
|
39
|
+
/^\/private\/var\/folders\//,
|
|
40
|
+
/^\/private\/tmp\//,
|
|
41
|
+
];
|
|
42
|
+
function isTempDir(cwd) {
|
|
43
|
+
if (!cwd)
|
|
44
|
+
return false;
|
|
45
|
+
return TEMP_DIR_PATTERNS.some((re) => re.test(cwd));
|
|
46
|
+
}
|
|
47
|
+
function slugForCwd(cwd) {
|
|
48
|
+
if (!cwd)
|
|
49
|
+
return 'unknown';
|
|
50
|
+
return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
51
|
+
}
|
|
52
|
+
function ensureProjectDir(slug) {
|
|
53
|
+
const dir = join(PROJECTS_DIR, slug);
|
|
54
|
+
if (!existsSync(dir))
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
function appendThreadEntry(slug, entry) {
|
|
59
|
+
// Returns true if this write SEEDED the file (first time creation), false otherwise.
|
|
60
|
+
// Caller (mirrorSpawnedToThread) uses this signal to fire the hidden Title agent.
|
|
61
|
+
try {
|
|
62
|
+
const dir = ensureProjectDir(slug);
|
|
63
|
+
const file = join(dir, 'thread.jsonl');
|
|
64
|
+
let seeded = false;
|
|
65
|
+
if (!existsSync(file)) {
|
|
66
|
+
const meta = JSON.stringify({
|
|
67
|
+
_meta: 'project',
|
|
68
|
+
slug,
|
|
69
|
+
cwd: null, // populated by migration; forward-mirror writes the cwd in each task entry
|
|
70
|
+
sessionCount: 0,
|
|
71
|
+
title: null, // populated by hidden Title agent (v0.6.0 M9) on first task entry
|
|
72
|
+
migratedFrom: 'forward-mirror',
|
|
73
|
+
migratedAt: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
appendFileSync(file, meta + '\n', 'utf-8');
|
|
76
|
+
seeded = true;
|
|
77
|
+
}
|
|
78
|
+
appendFileSync(file, JSON.stringify(entry) + '\n', 'utf-8');
|
|
79
|
+
return seeded;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Best-effort. Mirror failures must never break the spawn or completion
|
|
83
|
+
// path — sessions.jsonl is the canonical store; thread.jsonl is derived.
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Append a project-meta update line (used by Title agent to set the title). */
|
|
88
|
+
function appendMetaUpdate(slug, partial) {
|
|
89
|
+
try {
|
|
90
|
+
const dir = ensureProjectDir(slug);
|
|
91
|
+
const file = join(dir, 'thread.jsonl');
|
|
92
|
+
if (!existsSync(file))
|
|
93
|
+
return; // can't update what doesn't exist
|
|
94
|
+
appendFileSync(file, JSON.stringify({ _meta: 'project-update', slug, ...partial, updatedAt: Date.now() }) + '\n', 'utf-8');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// best-effort
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Mirror a spawn event to the project thread. Skips temp-dir cwds (matches
|
|
102
|
+
* migration defaults) so test-artifact dirs don't pollute the project list.
|
|
103
|
+
*
|
|
104
|
+
* If this spawn is the FIRST task in a brand-new project (i.e., thread.jsonl
|
|
105
|
+
* just got seeded), fire the hidden Title agent (v0.6.0 M9) in the background
|
|
106
|
+
* to generate a 4-6 word title from the first user prompt. The title appears
|
|
107
|
+
* in the project-meta as a 'project-update' record; readers take the latest
|
|
108
|
+
* value. Title generation is best-effort and silent on failure.
|
|
109
|
+
*/
|
|
110
|
+
export function mirrorSpawnedToThread(rec) {
|
|
111
|
+
if (isTempDir(rec.cwd))
|
|
112
|
+
return;
|
|
113
|
+
const slug = slugForCwd(rec.cwd);
|
|
114
|
+
const seeded = appendThreadEntry(slug, {
|
|
115
|
+
type: 'task',
|
|
116
|
+
sessionId: rec.sessionId,
|
|
117
|
+
agent: rec.agent,
|
|
118
|
+
prompt: rec.prompt,
|
|
119
|
+
cwd: rec.cwd,
|
|
120
|
+
startedAt: rec.startedAt,
|
|
121
|
+
status: 'running',
|
|
122
|
+
});
|
|
123
|
+
if (seeded && process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
|
|
124
|
+
// Fire Title agent in background; do not block the spawn path. Lazy-import
|
|
125
|
+
// to avoid loading the LLM module on every spawn when not needed.
|
|
126
|
+
void (async () => {
|
|
127
|
+
try {
|
|
128
|
+
const { generateThreadTitle } = await import('../../application/use-cases/generate-thread-title.js');
|
|
129
|
+
const title = await generateThreadTitle(rec.prompt, rec.cwd);
|
|
130
|
+
if (title) {
|
|
131
|
+
appendMetaUpdate(slug, { title, cwd: rec.cwd });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// best-effort
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Mirror a completion event to the project thread. Same skip rules as spawn.
|
|
142
|
+
* Reader reconstructs by sessionId — this entry's fields override the spawn
|
|
143
|
+
* entry's where they overlap (status flips from 'running' to actual final).
|
|
144
|
+
*/
|
|
145
|
+
export function mirrorCompletedToThread(rec) {
|
|
146
|
+
if (isTempDir(rec.cwd))
|
|
147
|
+
return;
|
|
148
|
+
const slug = slugForCwd(rec.cwd);
|
|
149
|
+
appendThreadEntry(slug, {
|
|
150
|
+
type: 'task',
|
|
151
|
+
sessionId: rec.sessionId,
|
|
152
|
+
status: rec.status,
|
|
153
|
+
durationMs: rec.durationMs,
|
|
154
|
+
completedAt: rec.completedAt,
|
|
155
|
+
steps: rec.steps,
|
|
156
|
+
artifacts: rec.artifacts,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* v0.6.x — mirror a successful appendMessage() reply to the project thread.
|
|
161
|
+
*
|
|
162
|
+
* Pre-fix: only initial spawn + completion wrote to thread.jsonl. Reply turns
|
|
163
|
+
* (the `/api/task/:id/message` endpoint, the dashboard reply box, and the new
|
|
164
|
+
* /threads reply bar) updated only the in-memory session. Result: /threads
|
|
165
|
+
* showed the original spawn but every follow-up turn was invisible. Founder
|
|
166
|
+
* flagged it 2026-05-03 as a v0.6.x cleanup item.
|
|
167
|
+
*
|
|
168
|
+
* Schema choice: write a single complete `{type: 'task', sessionId: <new>,
|
|
169
|
+
* parentSessionId: <orig>, ...}` entry rather than splitting spawn+complete.
|
|
170
|
+
* appendMessage is synchronous-from-caller's-POV (no streaming), so the whole
|
|
171
|
+
* turn is known when this fires. The reader's sessionId merge logic surfaces
|
|
172
|
+
* each reply as its own chronological row on /threads.
|
|
173
|
+
*/
|
|
174
|
+
export function mirrorReplyToThread(rec) {
|
|
175
|
+
if (isTempDir(rec.cwd))
|
|
176
|
+
return;
|
|
177
|
+
const slug = slugForCwd(rec.cwd);
|
|
178
|
+
appendThreadEntry(slug, {
|
|
179
|
+
type: 'task',
|
|
180
|
+
sessionId: rec.replySessionId,
|
|
181
|
+
parentSessionId: rec.parentSessionId,
|
|
182
|
+
agent: rec.agent,
|
|
183
|
+
prompt: rec.prompt,
|
|
184
|
+
cwd: rec.cwd,
|
|
185
|
+
startedAt: rec.startedAt,
|
|
186
|
+
completedAt: rec.completedAt,
|
|
187
|
+
durationMs: rec.durationMs,
|
|
188
|
+
status: 'completed',
|
|
189
|
+
...(rec.result ? { result: rec.result } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=project-thread-mirror.js.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project thread reader — v0.6.0 M1 (UI-prep slice).
|
|
3
|
+
*
|
|
4
|
+
* Reads ~/.codehere/projects/<slug>/thread.jsonl files and reconstructs
|
|
5
|
+
* the per-project thread state for API consumers (the new UI rendering
|
|
6
|
+
* the persistent project thread shape).
|
|
7
|
+
*
|
|
8
|
+
* Reconstruction rules:
|
|
9
|
+
* - One file per project, identified by slug (sha1(cwd)[:12])
|
|
10
|
+
* - First line is `{_meta: 'project', ...}` — project header
|
|
11
|
+
* - Subsequent lines are `{_meta: 'project-update', ...}` (latest wins per
|
|
12
|
+
* field — Title agent uses this to set the title) OR `{type: 'task', ...}`
|
|
13
|
+
* (per-session entries; later entries with same sessionId override
|
|
14
|
+
* earlier — covers spawn entry → completion entry update)
|
|
15
|
+
*
|
|
16
|
+
* NOT in scope of this module:
|
|
17
|
+
* - Writing (handled by project-thread-mirror + project-thread-migration)
|
|
18
|
+
* - UI rendering (the new thread view will consume this reader's output)
|
|
19
|
+
* - Live SSE updates (polling is fine for the alpha; SSE in v0.6.x patch)
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
|
|
25
|
+
/** List all known projects with summary stats. Cheap; no full task content. */
|
|
26
|
+
export function listProjects(options) {
|
|
27
|
+
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
28
|
+
if (!existsSync(root))
|
|
29
|
+
return [];
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = statSync(root);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (!stat.isDirectory())
|
|
38
|
+
return [];
|
|
39
|
+
const slugs = readdirSync(root).filter((entry) => {
|
|
40
|
+
const p = join(root, entry);
|
|
41
|
+
try {
|
|
42
|
+
return statSync(p).isDirectory();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
const summaries = [];
|
|
49
|
+
for (const slug of slugs) {
|
|
50
|
+
const thread = readProjectThread(slug, { projectsDir: root, taskLimit: 0 });
|
|
51
|
+
if (thread) {
|
|
52
|
+
summaries.push({
|
|
53
|
+
slug: thread.slug,
|
|
54
|
+
cwd: thread.cwd,
|
|
55
|
+
title: thread.title,
|
|
56
|
+
taskCount: thread.taskCount,
|
|
57
|
+
lastActivityAt: thread.lastActivityAt,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Sort by recency (most recent first)
|
|
62
|
+
summaries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
63
|
+
return summaries;
|
|
64
|
+
}
|
|
65
|
+
/** Read a single project thread by slug. Returns null if not found. */
|
|
66
|
+
export function readProjectThread(slug, options) {
|
|
67
|
+
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
68
|
+
const file = join(root, slug, 'thread.jsonl');
|
|
69
|
+
if (!existsSync(file))
|
|
70
|
+
return null;
|
|
71
|
+
let content;
|
|
72
|
+
try {
|
|
73
|
+
content = readFileSync(file, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
let cwd = null;
|
|
79
|
+
let title = null;
|
|
80
|
+
const tasksById = new Map();
|
|
81
|
+
for (const line of content.split('\n')) {
|
|
82
|
+
const trimmed = line.trim();
|
|
83
|
+
if (!trimmed)
|
|
84
|
+
continue;
|
|
85
|
+
let rec;
|
|
86
|
+
try {
|
|
87
|
+
rec = JSON.parse(trimmed);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (rec._meta === 'project') {
|
|
93
|
+
if (typeof rec.cwd === 'string')
|
|
94
|
+
cwd = rec.cwd;
|
|
95
|
+
if (typeof rec.title === 'string')
|
|
96
|
+
title = rec.title;
|
|
97
|
+
}
|
|
98
|
+
else if (rec._meta === 'project-update') {
|
|
99
|
+
if (typeof rec.title === 'string')
|
|
100
|
+
title = rec.title;
|
|
101
|
+
if (typeof rec.cwd === 'string' && cwd === null)
|
|
102
|
+
cwd = rec.cwd;
|
|
103
|
+
}
|
|
104
|
+
else if (rec.type === 'task' && typeof rec.sessionId === 'string') {
|
|
105
|
+
const existing = tasksById.get(rec.sessionId) ?? {
|
|
106
|
+
sessionId: rec.sessionId,
|
|
107
|
+
agent: '',
|
|
108
|
+
prompt: '',
|
|
109
|
+
cwd: '',
|
|
110
|
+
startedAt: 0,
|
|
111
|
+
};
|
|
112
|
+
// Merge fields — later entries override earlier (covers
|
|
113
|
+
// spawn → completion update pattern from forward-mirror)
|
|
114
|
+
for (const k of ['agent', 'prompt', 'cwd', 'startedAt', 'status', 'durationMs', 'completedAt', 'steps', 'artifacts']) {
|
|
115
|
+
if (rec[k] !== undefined) {
|
|
116
|
+
existing[k] = rec[k];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
tasksById.set(rec.sessionId, existing);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const tasks = [...tasksById.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
123
|
+
const lastActivityAt = tasks.reduce((acc, t) => {
|
|
124
|
+
const ts = t.completedAt ?? t.startedAt;
|
|
125
|
+
if (ts && (acc === null || ts > acc))
|
|
126
|
+
return ts;
|
|
127
|
+
return acc;
|
|
128
|
+
}, null);
|
|
129
|
+
const limit = options?.taskLimit;
|
|
130
|
+
const cappedTasks = limit === undefined ? tasks : limit === 0 ? [] : tasks.slice(-limit);
|
|
131
|
+
return {
|
|
132
|
+
slug,
|
|
133
|
+
cwd,
|
|
134
|
+
title,
|
|
135
|
+
taskCount: tasks.length,
|
|
136
|
+
tasks: cappedTasks,
|
|
137
|
+
lastActivityAt,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=project-thread-reader.js.map
|