codehere 0.8.0 → 1.0.2
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 +37 -184
- package/dist/application/use-cases/check-claimed-tests.js +81 -0
- package/dist/application/use-cases/detect-test-weakening.js +237 -0
- package/dist/application/use-cases/rerun-tests.js +329 -0
- package/dist/index.js +74 -1004
- package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -265
- package/dist/presentation/cli/commands/check-command.js +251 -0
- package/dist/presentation/cli/commands/help-command.js +22 -286
- package/dist/ui.js +19 -438
- package/dist/utils/version.js +0 -12
- package/package.json +18 -27
- package/dist/application/use-cases/domain-trust.js +0 -110
- package/dist/application/use-cases/generate-final-report.js +0 -134
- package/dist/application/use-cases/generate-narration.js +0 -128
- package/dist/application/use-cases/generate-thread-title.js +0 -178
- package/dist/application/use-cases/narrator-prompt-template.js +0 -158
- package/dist/application/use-cases/thread-export.js +0 -155
- package/dist/application/use-cases/verify-claim.js +0 -274
- package/dist/application/use-cases/write-pass.js +0 -277
- package/dist/domain/entities/task.js +0 -61
- package/dist/domain/services/codebase-detector.js +0 -91
- package/dist/error-handler.js +0 -251
- package/dist/infrastructure/completion/bash-completion.js +0 -25
- package/dist/infrastructure/completion/fish-completion.js +0 -22
- package/dist/infrastructure/completion/zsh-completion.js +0 -30
- package/dist/infrastructure/config/config-manager.js +0 -173
- package/dist/infrastructure/config/product-context.js +0 -67
- package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
- package/dist/infrastructure/context/adapters/figma-source.js +0 -163
- package/dist/infrastructure/context/adapters/github-issue-source.js +0 -159
- package/dist/infrastructure/context/adapters/linear-source.js +0 -190
- package/dist/infrastructure/context/context-resolver.js +0 -144
- package/dist/infrastructure/context/context-source.js +0 -114
- package/dist/infrastructure/context/oauth-flow.js +0 -207
- package/dist/infrastructure/context/oauth-store.js +0 -119
- package/dist/infrastructure/context/registry-bootstrap.js +0 -99
- package/dist/infrastructure/context/write-pass-resolver.js +0 -106
- package/dist/infrastructure/cost/budget-config.js +0 -113
- package/dist/infrastructure/cost/cost-router.js +0 -104
- package/dist/infrastructure/cost/spend-aggregator.js +0 -147
- package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -888
- package/dist/infrastructure/hooks/hook-mode.js +0 -71
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -43
- package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
- package/dist/infrastructure/hooks/session-analyzer.js +0 -177
- package/dist/infrastructure/observability/execution-tracer.js +0 -227
- package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
- package/dist/infrastructure/orchestration/agent-installer.js +0 -101
- package/dist/infrastructure/orchestration/agents-config-seed.js +0 -56
- package/dist/infrastructure/orchestration/aider-adapter.js +0 -447
- package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
- package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -478
- package/dist/infrastructure/orchestration/codex-adapter.js +0 -538
- package/dist/infrastructure/orchestration/conductor-adapter.js +0 -377
- package/dist/infrastructure/orchestration/context-handoff.js +0 -144
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
- package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
- package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
- package/dist/infrastructure/orchestration/failover-config.js +0 -77
- package/dist/infrastructure/orchestration/failover-policy.js +0 -106
- package/dist/infrastructure/orchestration/gemini-adapter.js +0 -583
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -558
- package/dist/infrastructure/orchestration/graphify-context.js +0 -254
- package/dist/infrastructure/orchestration/memory-injector.js +0 -380
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -233
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
- package/dist/infrastructure/orchestration/opencode-adapter.js +0 -510
- package/dist/infrastructure/orchestration/progress-aggregator.js +0 -689
- package/dist/infrastructure/orchestration/readiness.js +0 -103
- package/dist/infrastructure/orchestration/session-manager.js +0 -1091
- package/dist/infrastructure/orchestration/session-persistence.js +0 -258
- package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -46
- package/dist/infrastructure/protocols/cloud-sync.js +0 -207
- package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
- package/dist/infrastructure/protocols/mcp-server-impl.js +0 -2853
- package/dist/infrastructure/protocols/probe-port.js +0 -36
- package/dist/infrastructure/protocols/thread-phase.js +0 -105
- package/dist/infrastructure/protocols/ui-page-v2.js +0 -4418
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/protocols/verify-page.js +0 -983
- package/dist/infrastructure/security/file-collection.js +0 -118
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/security/permissions-config.js +0 -234
- package/dist/infrastructure/security/trust-scorer.js +0 -657
- package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/project-thread-migration.js +0 -237
- package/dist/infrastructure/storage/project-thread-mirror.js +0 -524
- package/dist/infrastructure/storage/project-thread-reader.js +0 -172
- package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
- package/dist/infrastructure/storage/task-repository.js +0 -206
- package/dist/monitoring.js +0 -212
- package/dist/presentation/cli/commands/audit-command.js +0 -126
- package/dist/presentation/cli/commands/budget-command.js +0 -79
- package/dist/presentation/cli/commands/config-command.js +0 -125
- package/dist/presentation/cli/commands/context-source-command.js +0 -198
- package/dist/presentation/cli/commands/hook-command.js +0 -265
- package/dist/presentation/cli/commands/init-command.js +0 -578
- package/dist/presentation/cli/commands/memory-command.js +0 -1031
- package/dist/presentation/cli/commands/narrator-command.js +0 -101
- package/dist/presentation/cli/commands/permission-command.js +0 -116
- package/dist/presentation/cli/commands/protocols-builder.js +0 -254
- package/dist/presentation/cli/commands/protocols-command.js +0 -84
- package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
- package/dist/presentation/cli/commands/report-command.js +0 -387
- package/dist/presentation/cli/commands/scan-tool-command.js +0 -94
- package/dist/presentation/cli/commands/setup-command.js +0 -307
- package/dist/presentation/cli/commands/status-command.js +0 -661
- package/dist/presentation/cli/commands/thread-command.js +0 -288
- package/dist/presentation/cli/commands/trust-command.js +0 -306
- package/dist/presentation/cli/commands/verify-command.js +0 -233
- package/dist/presentation/cli/error-display.js +0 -336
- package/dist/presentation/cli/repl.js +0 -797
- package/dist/presentation/cli/task-deliverable.js +0 -127
- package/dist/testing/cassette.js +0 -125
- package/dist/testing/replay-adapter.js +0 -166
|
@@ -1,524 +0,0 @@
|
|
|
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, readFileSync } from 'fs';
|
|
31
|
-
import { homedir } from 'os';
|
|
32
|
-
import { join } from 'path';
|
|
33
|
-
import { createHash } from 'crypto';
|
|
34
|
-
const PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
35
|
-
/** Match migration module's defaults. Same regex set kept in sync. */
|
|
36
|
-
const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
|
|
37
|
-
function isTempDir(cwd) {
|
|
38
|
-
if (!cwd)
|
|
39
|
-
return false;
|
|
40
|
-
return TEMP_DIR_PATTERNS.some((re) => re.test(cwd));
|
|
41
|
-
}
|
|
42
|
-
function slugForCwd(cwd) {
|
|
43
|
-
if (!cwd)
|
|
44
|
-
return 'unknown';
|
|
45
|
-
return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
46
|
-
}
|
|
47
|
-
function ensureProjectDir(slug) {
|
|
48
|
-
const dir = join(PROJECTS_DIR, slug);
|
|
49
|
-
if (!existsSync(dir))
|
|
50
|
-
mkdirSync(dir, { recursive: true });
|
|
51
|
-
return dir;
|
|
52
|
-
}
|
|
53
|
-
function appendThreadEntry(slug, entry) {
|
|
54
|
-
// Returns true if this write SEEDED the file (first time creation), false otherwise.
|
|
55
|
-
// Caller (mirrorSpawnedToThread) uses this signal to fire the hidden Title agent.
|
|
56
|
-
try {
|
|
57
|
-
const dir = ensureProjectDir(slug);
|
|
58
|
-
const file = join(dir, 'thread.jsonl');
|
|
59
|
-
let seeded = false;
|
|
60
|
-
if (!existsSync(file)) {
|
|
61
|
-
const meta = JSON.stringify({
|
|
62
|
-
_meta: 'project',
|
|
63
|
-
slug,
|
|
64
|
-
cwd: null, // populated by migration; forward-mirror writes the cwd in each task entry
|
|
65
|
-
sessionCount: 0,
|
|
66
|
-
title: null, // populated by hidden Title agent (v0.6.0 M9) on first task entry
|
|
67
|
-
migratedFrom: 'forward-mirror',
|
|
68
|
-
migratedAt: Date.now(),
|
|
69
|
-
});
|
|
70
|
-
appendFileSync(file, meta + '\n', 'utf-8');
|
|
71
|
-
seeded = true;
|
|
72
|
-
}
|
|
73
|
-
appendFileSync(file, JSON.stringify(entry) + '\n', 'utf-8');
|
|
74
|
-
return seeded;
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
// Best-effort. Mirror failures must never break the spawn or completion
|
|
78
|
-
// path — sessions.jsonl is the canonical store; thread.jsonl is derived.
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
/** Append a project-meta update line (used by Title agent to set the title).
|
|
83
|
-
* Returns true on successful write, false on any fs failure. Title-agent
|
|
84
|
-
* callers ignore the return (best-effort); renameProject surfaces it as a
|
|
85
|
-
* 500 to the HTTP layer so users don't see "renamed" when nothing landed. */
|
|
86
|
-
function appendMetaUpdate(slug, partial) {
|
|
87
|
-
try {
|
|
88
|
-
const dir = ensureProjectDir(slug);
|
|
89
|
-
const file = join(dir, 'thread.jsonl');
|
|
90
|
-
if (!existsSync(file))
|
|
91
|
-
return false; // can't update what doesn't exist
|
|
92
|
-
appendFileSync(file, JSON.stringify({ _meta: 'project-update', slug, ...partial, updatedAt: Date.now() }) + '\n', 'utf-8');
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Returns true if any prior project-update line for this slug carries
|
|
101
|
-
* userOverride:true. Used by the async Title agent (and the periodic backfill)
|
|
102
|
-
* to skip overwriting a title the user has manually set.
|
|
103
|
-
*
|
|
104
|
-
* The race this guards: spawn fires Title agent in background → user renames
|
|
105
|
-
* before LLM returns → Title agent's later write would clobber the manual
|
|
106
|
-
* rename because the reader takes the latest title. With this check, the
|
|
107
|
-
* Title agent reads its own outcome moments before writing and bails if the
|
|
108
|
-
* user got there first. Best-effort; on read failure returns false (Title
|
|
109
|
-
* agent proceeds) — same posture as every other appendThreadEntry caller.
|
|
110
|
-
*/
|
|
111
|
-
/**
|
|
112
|
-
* Derive a clean Title-Cased name from a working directory path. Used as the
|
|
113
|
-
* immediate seed title (so the sidebar never shows blank / placeholder during
|
|
114
|
-
* the LLM call) and as the permanent fallback when the Title agent returns
|
|
115
|
-
* null / garbage. Examples:
|
|
116
|
-
* /Users/x/code/codehere → "Codehere"
|
|
117
|
-
* /Users/x/work/auth_service → "Auth Service"
|
|
118
|
-
* /Users/x/code/some-cool-thing → "Some Cool Thing"
|
|
119
|
-
* / → "Project"
|
|
120
|
-
*/
|
|
121
|
-
export function titleFromCwd(cwd) {
|
|
122
|
-
if (!cwd)
|
|
123
|
-
return 'Project';
|
|
124
|
-
const segments = cwd.split('/').filter(Boolean);
|
|
125
|
-
const last = segments[segments.length - 1];
|
|
126
|
-
if (!last)
|
|
127
|
-
return 'Project';
|
|
128
|
-
// Replace separators with spaces, title-case each word, cap at 6 words
|
|
129
|
-
const words = last
|
|
130
|
-
.replace(/[._-]+/g, ' ')
|
|
131
|
-
.replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase split
|
|
132
|
-
.split(/\s+/)
|
|
133
|
-
.filter(Boolean)
|
|
134
|
-
.slice(0, 6)
|
|
135
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
|
|
136
|
-
return words.join(' ') || 'Project';
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* One-shot backfill for legacy garbage project titles (Bug A4 retroactive).
|
|
140
|
-
*
|
|
141
|
-
* The original A4 fix (rejected garbage titles + cwd fallback) only applied
|
|
142
|
-
* to NEW projects. Existing thread.jsonl files written before the fix can
|
|
143
|
-
* still carry titles like "Request ID: req_abc123…" or "I cannot generate…"
|
|
144
|
-
* sitting in the latest project-update record. Those show up in the sidebar
|
|
145
|
-
* forever until the user manually renames.
|
|
146
|
-
*
|
|
147
|
-
* Walks every ~/.codehere/projects/<slug>/thread.jsonl, reads the latest
|
|
148
|
-
* project-update title, runs the same garbage detection used at write time.
|
|
149
|
-
* If garbage, appends a new project-update with the cwd-derived fallback
|
|
150
|
-
* title plus `_backfilled: true` marker so re-runs are no-ops.
|
|
151
|
-
*
|
|
152
|
-
* Manual overrides (set via Pattern 10 inline rename) are NEVER touched —
|
|
153
|
-
* hasManualOverride guards the rewrite.
|
|
154
|
-
*
|
|
155
|
-
* Best-effort: any per-project failure logs to stderr (when CODEHERE_DEBUG=1)
|
|
156
|
-
* and continues. Returns the count of titles rewritten.
|
|
157
|
-
*/
|
|
158
|
-
export async function backfillProjectTitles() {
|
|
159
|
-
let scanned = 0;
|
|
160
|
-
let rewritten = 0;
|
|
161
|
-
if (!existsSync(PROJECTS_DIR))
|
|
162
|
-
return { scanned, rewritten };
|
|
163
|
-
// Lazy-load sanitizeTitle to avoid a static import cycle (use-case → infra).
|
|
164
|
-
const { sanitizeTitle } = await import('../../application/use-cases/generate-thread-title.js');
|
|
165
|
-
let entries;
|
|
166
|
-
try {
|
|
167
|
-
const { readdirSync } = await import('fs');
|
|
168
|
-
entries = readdirSync(PROJECTS_DIR);
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return { scanned, rewritten };
|
|
172
|
-
}
|
|
173
|
-
for (const slug of entries) {
|
|
174
|
-
// Skip non-slug-shaped names + hidden files
|
|
175
|
-
if (!/^[a-f0-9]{12}$|^unknown$/.test(slug))
|
|
176
|
-
continue;
|
|
177
|
-
scanned++;
|
|
178
|
-
try {
|
|
179
|
-
const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
|
|
180
|
-
if (!existsSync(file))
|
|
181
|
-
continue;
|
|
182
|
-
if (hasManualOverride(slug))
|
|
183
|
-
continue; // Never overwrite user renames
|
|
184
|
-
const content = readFileSync(file, 'utf-8');
|
|
185
|
-
const lines = content.split('\n').filter((l) => l.trim());
|
|
186
|
-
// Find the latest project-update + the most recent task cwd (for fallback)
|
|
187
|
-
let latestTitle = null;
|
|
188
|
-
let latestCwd = null;
|
|
189
|
-
let alreadyBackfilled = false;
|
|
190
|
-
for (const line of lines) {
|
|
191
|
-
try {
|
|
192
|
-
const rec = JSON.parse(line);
|
|
193
|
-
if (rec._meta === 'project-update' && typeof rec.title === 'string') {
|
|
194
|
-
latestTitle = rec.title;
|
|
195
|
-
if (rec._backfilled === true)
|
|
196
|
-
alreadyBackfilled = true;
|
|
197
|
-
else
|
|
198
|
-
alreadyBackfilled = false;
|
|
199
|
-
}
|
|
200
|
-
if (rec.type === 'task' && typeof rec.cwd === 'string' && rec.cwd) {
|
|
201
|
-
latestCwd = rec.cwd;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
/* skip malformed line */
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (alreadyBackfilled)
|
|
209
|
-
continue; // idempotent — already cleaned
|
|
210
|
-
if (!latestTitle)
|
|
211
|
-
continue; // nothing to check
|
|
212
|
-
// Run the same garbage detection sanitizeTitle uses at write time.
|
|
213
|
-
// sanitizeTitle returns '' for garbage; non-empty means clean.
|
|
214
|
-
const cleaned = sanitizeTitle(latestTitle);
|
|
215
|
-
if (cleaned)
|
|
216
|
-
continue; // title is fine
|
|
217
|
-
// Garbage — write a new project-update with cwd-derived fallback.
|
|
218
|
-
const fallback = titleFromCwd(latestCwd);
|
|
219
|
-
const ok = appendMetaUpdate(slug, {
|
|
220
|
-
title: fallback,
|
|
221
|
-
cwd: latestCwd,
|
|
222
|
-
_backfilled: true,
|
|
223
|
-
});
|
|
224
|
-
if (ok) {
|
|
225
|
-
rewritten++;
|
|
226
|
-
if (process.env.CODEHERE_DEBUG === '1') {
|
|
227
|
-
console.error(`[backfill] ${slug}: "${latestTitle.slice(0, 40)}" → "${fallback}"`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
/* per-project failure → continue */
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return { scanned, rewritten };
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* One-shot reconciliation for stale-running tasks (Bug: project-thread.jsonl
|
|
239
|
-
* entries can stay in `status: 'running'` forever when the daemon dies
|
|
240
|
-
* mid-task or the subprocess crashes without a clean completion event).
|
|
241
|
-
*
|
|
242
|
-
* The visible failure mode (per dogfood screenshots May 6): the threads
|
|
243
|
-
* view shows RUNNING pills on 3-day-old tasks. With Pattern 8's peach-
|
|
244
|
-
* accent running pill, the page reads as "codehere doesn't know what
|
|
245
|
-
* state its own sessions are in" — a credibility bomb.
|
|
246
|
-
*
|
|
247
|
-
* Walks every ~/.codehere/projects/<slug>/thread.jsonl, reconstructs each
|
|
248
|
-
* session's latest task state by sessionId, and for any session whose
|
|
249
|
-
* latest state is `running` AND startedAt > 60 minutes ago, appends a
|
|
250
|
-
* synthetic completion record marking it `crashed` with `_reconciled: true`.
|
|
251
|
-
*
|
|
252
|
-
* Idempotent — sessions already carrying `_reconciled: true` are skipped.
|
|
253
|
-
* Preserves history (append-only, never rewrites prior lines).
|
|
254
|
-
*
|
|
255
|
-
* Best-effort: per-project failures log to stderr (CODEHERE_DEBUG=1) and
|
|
256
|
-
* continue. Returns count of sessions reconciled.
|
|
257
|
-
*/
|
|
258
|
-
export async function reconcileStaleProjectTasks(options) {
|
|
259
|
-
const STALE_AFTER_MS = options?.staleAfterMs ?? 60 * 60 * 1000; // 60 minutes
|
|
260
|
-
const now = Date.now();
|
|
261
|
-
let scanned = 0;
|
|
262
|
-
let reconciled = 0;
|
|
263
|
-
if (!existsSync(PROJECTS_DIR))
|
|
264
|
-
return { scanned, reconciled };
|
|
265
|
-
let entries;
|
|
266
|
-
try {
|
|
267
|
-
const { readdirSync } = await import('fs');
|
|
268
|
-
entries = readdirSync(PROJECTS_DIR);
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
return { scanned, reconciled };
|
|
272
|
-
}
|
|
273
|
-
for (const slug of entries) {
|
|
274
|
-
if (!/^[a-f0-9]{12}$|^unknown$/.test(slug))
|
|
275
|
-
continue;
|
|
276
|
-
scanned++;
|
|
277
|
-
try {
|
|
278
|
-
const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
|
|
279
|
-
if (!existsSync(file))
|
|
280
|
-
continue;
|
|
281
|
-
const content = readFileSync(file, 'utf-8');
|
|
282
|
-
const lines = content.split('\n').filter((l) => l.trim());
|
|
283
|
-
const bySession = new Map();
|
|
284
|
-
for (const line of lines) {
|
|
285
|
-
try {
|
|
286
|
-
const rec = JSON.parse(line);
|
|
287
|
-
if (rec.type !== 'task' || typeof rec.sessionId !== 'string')
|
|
288
|
-
continue;
|
|
289
|
-
const cur = bySession.get(rec.sessionId) ?? {
|
|
290
|
-
sessionId: rec.sessionId,
|
|
291
|
-
status: '',
|
|
292
|
-
startedAt: 0,
|
|
293
|
-
reconciled: false,
|
|
294
|
-
};
|
|
295
|
-
if (typeof rec.status === 'string')
|
|
296
|
-
cur.status = rec.status;
|
|
297
|
-
if (typeof rec.startedAt === 'number' && rec.startedAt > 0)
|
|
298
|
-
cur.startedAt = rec.startedAt;
|
|
299
|
-
if (rec._reconciled === true)
|
|
300
|
-
cur.reconciled = true;
|
|
301
|
-
bySession.set(rec.sessionId, cur);
|
|
302
|
-
}
|
|
303
|
-
catch {
|
|
304
|
-
/* skip malformed line */
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
// Find stale-running candidates and append synthetic completions.
|
|
308
|
-
for (const state of bySession.values()) {
|
|
309
|
-
if (state.status !== 'running')
|
|
310
|
-
continue;
|
|
311
|
-
if (state.reconciled)
|
|
312
|
-
continue; // already cleaned
|
|
313
|
-
if (state.startedAt <= 0)
|
|
314
|
-
continue; // safety: no time data
|
|
315
|
-
if (now - state.startedAt < STALE_AFTER_MS)
|
|
316
|
-
continue;
|
|
317
|
-
// appendThreadEntry returns `seeded` (first-write flag), not a
|
|
318
|
-
// success signal. For an already-seeded file, the return is always
|
|
319
|
-
// false on both success and failure. Best-effort approach: count
|
|
320
|
-
// optimistically — write failures are rare and bounded by the
|
|
321
|
-
// function's own try/catch.
|
|
322
|
-
appendThreadEntry(slug, {
|
|
323
|
-
type: 'task',
|
|
324
|
-
sessionId: state.sessionId,
|
|
325
|
-
status: 'crashed',
|
|
326
|
-
completedAt: now,
|
|
327
|
-
durationMs: now - state.startedAt,
|
|
328
|
-
_reconciled: true,
|
|
329
|
-
});
|
|
330
|
-
reconciled++;
|
|
331
|
-
if (process.env.CODEHERE_DEBUG === '1') {
|
|
332
|
-
const ageMin = Math.round((now - state.startedAt) / 60000);
|
|
333
|
-
console.error(`[reconcile] ${slug}/${state.sessionId.slice(0, 8)} stale running for ${ageMin}m → crashed`);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
catch {
|
|
338
|
-
/* per-project failure → continue */
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
return { scanned, reconciled };
|
|
342
|
-
}
|
|
343
|
-
export function hasManualOverride(slug) {
|
|
344
|
-
try {
|
|
345
|
-
const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
|
|
346
|
-
if (!existsSync(file))
|
|
347
|
-
return false;
|
|
348
|
-
const content = readFileSync(file, 'utf-8');
|
|
349
|
-
for (const line of content.split('\n')) {
|
|
350
|
-
const t = line.trim();
|
|
351
|
-
if (!t)
|
|
352
|
-
continue;
|
|
353
|
-
try {
|
|
354
|
-
const rec = JSON.parse(t);
|
|
355
|
-
if (rec._meta === 'project-update' && rec.userOverride === true)
|
|
356
|
-
return true;
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
// skip malformed line
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return false;
|
|
363
|
-
}
|
|
364
|
-
catch {
|
|
365
|
-
return false;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* v0.6.x — user-initiated rename. The Title agent generates a title from the
|
|
370
|
-
* first prompt, but project intent drifts (e.g. /agent dir gets titled "Scan
|
|
371
|
-
* Auth File" forever even after 100+ unrelated tasks). This gives the user a
|
|
372
|
-
* mechanism to override that title without re-firing the LLM-based agent.
|
|
373
|
-
*
|
|
374
|
-
* Returns { ok: false, error } on validation/fs failure so the HTTP layer can
|
|
375
|
-
* surface a meaningful 4xx instead of a silent best-effort write. Note: this
|
|
376
|
-
* is the only project-update path that returns errors — Title agent
|
|
377
|
-
* appendMetaUpdate is silent because LLM hiccups are normal.
|
|
378
|
-
*/
|
|
379
|
-
export function renameProject(slug, newTitle) {
|
|
380
|
-
// Validate slug shape — guards against path traversal in case the caller
|
|
381
|
-
// doesn't sanitize the URL parameter (HTTP layer validates this too, defense in depth).
|
|
382
|
-
if (!/^([a-f0-9]{12}|unknown)$/.test(slug)) {
|
|
383
|
-
return { ok: false, error: 'Invalid project slug' };
|
|
384
|
-
}
|
|
385
|
-
// Validate title — strip whitespace, enforce length and single-line.
|
|
386
|
-
const trimmed = newTitle.trim();
|
|
387
|
-
if (!trimmed)
|
|
388
|
-
return { ok: false, error: 'Title cannot be empty' };
|
|
389
|
-
if (trimmed.length > 100)
|
|
390
|
-
return { ok: false, error: 'Title too long (max 100 chars)' };
|
|
391
|
-
if (/[\n\r]/.test(trimmed))
|
|
392
|
-
return { ok: false, error: 'Title cannot contain newlines' };
|
|
393
|
-
// Verify project exists — appendMetaUpdate is silent on missing files but
|
|
394
|
-
// we want a clean 404 path for the HTTP caller.
|
|
395
|
-
const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
|
|
396
|
-
if (!existsSync(file))
|
|
397
|
-
return { ok: false, error: 'Project not found' };
|
|
398
|
-
// userOverride:true tells the async Title agent + periodic backfill to skip
|
|
399
|
-
// writing for this slug — see hasManualOverride() above. Without this flag,
|
|
400
|
-
// a slow LLM call from spawn-time can land AFTER the user's rename and
|
|
401
|
-
// clobber it (the reader takes the latest project-update title).
|
|
402
|
-
if (!appendMetaUpdate(slug, { title: trimmed, userOverride: true })) {
|
|
403
|
-
return { ok: false, error: 'Write failed' };
|
|
404
|
-
}
|
|
405
|
-
return { ok: true };
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Mirror a spawn event to the project thread. Skips temp-dir cwds (matches
|
|
409
|
-
* migration defaults) so test-artifact dirs don't pollute the project list.
|
|
410
|
-
*
|
|
411
|
-
* If this spawn is the FIRST task in a brand-new project (i.e., thread.jsonl
|
|
412
|
-
* just got seeded), fire the hidden Title agent (v0.6.0 M9) in the background
|
|
413
|
-
* to generate a 4-6 word title from the first user prompt. The title appears
|
|
414
|
-
* in the project-meta as a 'project-update' record; readers take the latest
|
|
415
|
-
* value. Title generation is best-effort and silent on failure.
|
|
416
|
-
*/
|
|
417
|
-
export function mirrorSpawnedToThread(rec) {
|
|
418
|
-
if (isTempDir(rec.cwd))
|
|
419
|
-
return;
|
|
420
|
-
const slug = slugForCwd(rec.cwd);
|
|
421
|
-
const seeded = appendThreadEntry(slug, {
|
|
422
|
-
type: 'task',
|
|
423
|
-
sessionId: rec.sessionId,
|
|
424
|
-
agent: rec.agent,
|
|
425
|
-
prompt: rec.prompt,
|
|
426
|
-
cwd: rec.cwd,
|
|
427
|
-
startedAt: rec.startedAt,
|
|
428
|
-
status: 'running',
|
|
429
|
-
});
|
|
430
|
-
if (seeded) {
|
|
431
|
-
// Seed an immediate cwd-derived title so the sidebar never shows a blank
|
|
432
|
-
// / placeholder while the LLM call is in flight (or if it fails entirely).
|
|
433
|
-
// The Title agent can later overwrite this with a richer prompt-derived
|
|
434
|
-
// title; user manual rename always wins over both.
|
|
435
|
-
if (!hasManualOverride(slug)) {
|
|
436
|
-
const fallback = titleFromCwd(rec.cwd);
|
|
437
|
-
appendMetaUpdate(slug, { title: fallback, cwd: rec.cwd });
|
|
438
|
-
}
|
|
439
|
-
if (process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
|
|
440
|
-
// Fire Title agent in background; do not block the spawn path. Lazy-import
|
|
441
|
-
// to avoid loading the LLM module on every spawn when not needed.
|
|
442
|
-
void (async () => {
|
|
443
|
-
try {
|
|
444
|
-
const { generateThreadTitle } = await import('../../application/use-cases/generate-thread-title.js');
|
|
445
|
-
const title = await generateThreadTitle(rec.prompt, rec.cwd);
|
|
446
|
-
// Last-moment manual-override check: between spawn and now (LLM call
|
|
447
|
-
// can take seconds), user may have already renamed. Don't clobber.
|
|
448
|
-
// Also: if Title agent returned null (garbage rejected, provider
|
|
449
|
-
// unreachable, refusal), keep the cwd-derived fallback already seeded
|
|
450
|
-
// above — never write `(untitled)` or empty.
|
|
451
|
-
if (title && !hasManualOverride(slug)) {
|
|
452
|
-
appendMetaUpdate(slug, { title, cwd: rec.cwd });
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
catch {
|
|
456
|
-
// best-effort — cwd fallback already seeded
|
|
457
|
-
}
|
|
458
|
-
})();
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* Mirror a completion event to the project thread. Same skip rules as spawn.
|
|
464
|
-
* Reader reconstructs by sessionId — this entry's fields override the spawn
|
|
465
|
-
* entry's where they overlap (status flips from 'running' to actual final).
|
|
466
|
-
*/
|
|
467
|
-
export function mirrorCompletedToThread(rec) {
|
|
468
|
-
if (isTempDir(rec.cwd))
|
|
469
|
-
return;
|
|
470
|
-
const slug = slugForCwd(rec.cwd);
|
|
471
|
-
appendThreadEntry(slug, {
|
|
472
|
-
type: 'task',
|
|
473
|
-
sessionId: rec.sessionId,
|
|
474
|
-
status: rec.status,
|
|
475
|
-
durationMs: rec.durationMs,
|
|
476
|
-
completedAt: rec.completedAt,
|
|
477
|
-
steps: rec.steps,
|
|
478
|
-
artifacts: rec.artifacts,
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
/** Label map for raw error codes → user-facing text. */
|
|
482
|
-
const FAILOVER_LABELS = {
|
|
483
|
-
rate_limit: 'hit a rate limit',
|
|
484
|
-
quota_exceeded: 'exhausted its quota',
|
|
485
|
-
credit_low: 'ran low on credits',
|
|
486
|
-
error: 'encountered an error',
|
|
487
|
-
};
|
|
488
|
-
export function mirrorFailoverToThread(rec) {
|
|
489
|
-
if (isTempDir(rec.cwd))
|
|
490
|
-
return;
|
|
491
|
-
const slug = slugForCwd(rec.cwd);
|
|
492
|
-
const reasonLabel = FAILOVER_LABELS[rec.reason] ?? 'became unavailable';
|
|
493
|
-
const fromLabel = rec.fromAgent;
|
|
494
|
-
const toLabel = rec.toAgent;
|
|
495
|
-
appendThreadEntry(slug, {
|
|
496
|
-
type: 'task',
|
|
497
|
-
sessionId: rec.sessionId,
|
|
498
|
-
agent: 'system',
|
|
499
|
-
prompt: `↻ Switched from ${fromLabel} to ${toLabel} (${reasonLabel})`,
|
|
500
|
-
status: 'completed',
|
|
501
|
-
startedAt: Date.now(),
|
|
502
|
-
completedAt: Date.now(),
|
|
503
|
-
durationMs: 0,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
export function mirrorReplyToThread(rec) {
|
|
507
|
-
if (isTempDir(rec.cwd))
|
|
508
|
-
return;
|
|
509
|
-
const slug = slugForCwd(rec.cwd);
|
|
510
|
-
appendThreadEntry(slug, {
|
|
511
|
-
type: 'task',
|
|
512
|
-
sessionId: rec.replySessionId,
|
|
513
|
-
parentSessionId: rec.parentSessionId,
|
|
514
|
-
agent: rec.agent,
|
|
515
|
-
prompt: rec.prompt,
|
|
516
|
-
cwd: rec.cwd,
|
|
517
|
-
startedAt: rec.startedAt,
|
|
518
|
-
completedAt: rec.completedAt,
|
|
519
|
-
durationMs: rec.durationMs,
|
|
520
|
-
status: 'completed',
|
|
521
|
-
...(rec.result ? { result: rec.result } : {}),
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
//# sourceMappingURL=project-thread-mirror.js.map
|
|
@@ -1,172 +0,0 @@
|
|
|
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(process.env.CODEHERE_HOME || 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
|
-
// Pattern 4 — sidebar presence dots need agent attribution per project.
|
|
51
|
-
// Pull the full task list (lightweight — agent + startedAt only) so we
|
|
52
|
-
// can compute lastAgent + recentAgents (multi-agent stack). taskLimit:0
|
|
53
|
-
// would drop the data we need; fetch unbounded but discard the heavy
|
|
54
|
-
// fields (steps + artifacts) below.
|
|
55
|
-
const thread = readProjectThread(slug, { projectsDir: root });
|
|
56
|
-
if (thread) {
|
|
57
|
-
const agentChrono = thread.tasks
|
|
58
|
-
.filter((t) => typeof t.agent === 'string' && t.agent)
|
|
59
|
-
.sort((a, b) => b.startedAt - a.startedAt) // most-recent first
|
|
60
|
-
.map((t) => t.agent);
|
|
61
|
-
const lastAgent = agentChrono.length > 0 ? agentChrono[0] : null;
|
|
62
|
-
const seen = new Set();
|
|
63
|
-
const recentAgents = [];
|
|
64
|
-
for (const a of agentChrono) {
|
|
65
|
-
if (seen.has(a))
|
|
66
|
-
continue;
|
|
67
|
-
seen.add(a);
|
|
68
|
-
recentAgents.push(a);
|
|
69
|
-
if (recentAgents.length >= 3)
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
summaries.push({
|
|
73
|
-
slug: thread.slug,
|
|
74
|
-
cwd: thread.cwd,
|
|
75
|
-
title: thread.title,
|
|
76
|
-
taskCount: thread.taskCount,
|
|
77
|
-
lastActivityAt: thread.lastActivityAt,
|
|
78
|
-
lastAgent,
|
|
79
|
-
recentAgents,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Sort by recency (most recent first)
|
|
84
|
-
summaries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
85
|
-
return summaries;
|
|
86
|
-
}
|
|
87
|
-
/** Read a single project thread by slug. Returns null if not found. */
|
|
88
|
-
export function readProjectThread(slug, options) {
|
|
89
|
-
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
90
|
-
const file = join(root, slug, 'thread.jsonl');
|
|
91
|
-
if (!existsSync(file))
|
|
92
|
-
return null;
|
|
93
|
-
let content;
|
|
94
|
-
try {
|
|
95
|
-
content = readFileSync(file, 'utf-8');
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
let cwd = null;
|
|
101
|
-
let title = null;
|
|
102
|
-
const tasksById = new Map();
|
|
103
|
-
for (const line of content.split('\n')) {
|
|
104
|
-
const trimmed = line.trim();
|
|
105
|
-
if (!trimmed)
|
|
106
|
-
continue;
|
|
107
|
-
let rec;
|
|
108
|
-
try {
|
|
109
|
-
rec = JSON.parse(trimmed);
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (rec._meta === 'project') {
|
|
115
|
-
if (typeof rec.cwd === 'string')
|
|
116
|
-
cwd = rec.cwd;
|
|
117
|
-
if (typeof rec.title === 'string')
|
|
118
|
-
title = rec.title;
|
|
119
|
-
}
|
|
120
|
-
else if (rec._meta === 'project-update') {
|
|
121
|
-
if (typeof rec.title === 'string')
|
|
122
|
-
title = rec.title;
|
|
123
|
-
if (typeof rec.cwd === 'string' && cwd === null)
|
|
124
|
-
cwd = rec.cwd;
|
|
125
|
-
}
|
|
126
|
-
else if (rec.type === 'task' && typeof rec.sessionId === 'string') {
|
|
127
|
-
const existing = tasksById.get(rec.sessionId) ?? {
|
|
128
|
-
sessionId: rec.sessionId,
|
|
129
|
-
agent: '',
|
|
130
|
-
prompt: '',
|
|
131
|
-
cwd: '',
|
|
132
|
-
startedAt: 0,
|
|
133
|
-
};
|
|
134
|
-
// Merge fields — later entries override earlier (covers
|
|
135
|
-
// spawn → completion update pattern from forward-mirror)
|
|
136
|
-
for (const k of [
|
|
137
|
-
'agent',
|
|
138
|
-
'prompt',
|
|
139
|
-
'cwd',
|
|
140
|
-
'startedAt',
|
|
141
|
-
'status',
|
|
142
|
-
'durationMs',
|
|
143
|
-
'completedAt',
|
|
144
|
-
'steps',
|
|
145
|
-
'artifacts',
|
|
146
|
-
]) {
|
|
147
|
-
if (rec[k] !== undefined) {
|
|
148
|
-
existing[k] = rec[k];
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
tasksById.set(rec.sessionId, existing);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const tasks = [...tasksById.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
155
|
-
const lastActivityAt = tasks.reduce((acc, t) => {
|
|
156
|
-
const ts = t.completedAt ?? t.startedAt;
|
|
157
|
-
if (ts && (acc === null || ts > acc))
|
|
158
|
-
return ts;
|
|
159
|
-
return acc;
|
|
160
|
-
}, null);
|
|
161
|
-
const limit = options?.taskLimit;
|
|
162
|
-
const cappedTasks = limit === undefined ? tasks : limit === 0 ? [] : tasks.slice(-limit);
|
|
163
|
-
return {
|
|
164
|
-
slug,
|
|
165
|
-
cwd,
|
|
166
|
-
title,
|
|
167
|
-
taskCount: tasks.length,
|
|
168
|
-
tasks: cappedTasks,
|
|
169
|
-
lastActivityAt,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
//# sourceMappingURL=project-thread-reader.js.map
|