codehere 0.5.0-alpha.6 → 0.6.0-alpha.1
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 +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +75 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +95 -9
- package/dist/infrastructure/protocols/mcp-server-impl.js +533 -14
- package/dist/infrastructure/protocols/ui-page.js +2598 -212
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +346 -24
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +1 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-cwd permissions config — P18 slice 1 primitive.
|
|
3
|
+
*
|
|
4
|
+
* Reads `.codehere/permissions.yaml` from a project's cwd and lets the
|
|
5
|
+
* trust-scorer SKIP findings whose matched text fits an allow pattern.
|
|
6
|
+
*
|
|
7
|
+
* Use case: codehere blocks `rm -rf node_modules` because the pattern
|
|
8
|
+
* matches `rm -rf /`. The user knows it's safe in THIS cwd. They run
|
|
9
|
+
* `codehere permission allow "rm -rf node_modules"` (slice 2 CLI) and
|
|
10
|
+
* future blocks for that exact pattern in this cwd are skipped.
|
|
11
|
+
*
|
|
12
|
+
* Schema (.codehere/permissions.yaml):
|
|
13
|
+
*
|
|
14
|
+
* allow:
|
|
15
|
+
* - pattern: "rm -rf node_modules"
|
|
16
|
+
* reason: "OK to nuke deps in this repo"
|
|
17
|
+
* addedAt: 2026-05-08T20:00:00Z
|
|
18
|
+
* - pattern: "ANTHROPIC_API_KEY"
|
|
19
|
+
* reason: "Doc explains env vars; not actually leaking"
|
|
20
|
+
*
|
|
21
|
+
* Allow patterns are STRINGS not regex — exact substring match against
|
|
22
|
+
* the audit-finding `matched` field. Keeps the schema simple +
|
|
23
|
+
* non-technical-user friendly. Regex support is a v0.7 follow-up.
|
|
24
|
+
*
|
|
25
|
+
* Per claim_user_persona HIGH: the YAML is human-readable; non-
|
|
26
|
+
* technical users can edit it directly. Per overconfidence-guard:
|
|
27
|
+
* we DON'T claim the allow-list is "secure" — it's user CONSENT,
|
|
28
|
+
* not codehere certification.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync } from 'fs';
|
|
31
|
+
import { join } from 'path';
|
|
32
|
+
const EMPTY = { allow: [], deny: [] };
|
|
33
|
+
/**
|
|
34
|
+
* Load .codehere/permissions.yaml from a cwd. Returns empty config if
|
|
35
|
+
* the file doesn't exist or fails to parse. Defensive: never throws —
|
|
36
|
+
* caller treats absence as "no overrides", which is the safest default
|
|
37
|
+
* (the scanner blocks normally).
|
|
38
|
+
*/
|
|
39
|
+
export function loadPermissions(cwd) {
|
|
40
|
+
if (!cwd || typeof cwd !== 'string')
|
|
41
|
+
return EMPTY;
|
|
42
|
+
const file = join(cwd, '.codehere', 'permissions.yaml');
|
|
43
|
+
if (!existsSync(file))
|
|
44
|
+
return EMPTY;
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = readFileSync(file, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return EMPTY;
|
|
51
|
+
}
|
|
52
|
+
return parsePermissionsYaml(raw);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Tiny YAML subset parser — only handles the shape this file uses
|
|
56
|
+
* (top-level allow/deny lists of {pattern, reason, addedAt} objects).
|
|
57
|
+
* No external dep; the codehere repo already avoids the `yaml` package
|
|
58
|
+
* for read paths to keep cold-start fast. Format errors yield empty
|
|
59
|
+
* config (same posture as missing file).
|
|
60
|
+
*/
|
|
61
|
+
export function parsePermissionsYaml(raw) {
|
|
62
|
+
const cfg = { allow: [], deny: [] };
|
|
63
|
+
let bucket = null;
|
|
64
|
+
let pending = null;
|
|
65
|
+
for (const line of raw.split('\n')) {
|
|
66
|
+
const stripped = line.replace(/#.*$/, '').trimEnd();
|
|
67
|
+
if (!stripped.trim())
|
|
68
|
+
continue;
|
|
69
|
+
// Top-level key: "allow:" or "deny:"
|
|
70
|
+
const top = stripped.match(/^(allow|deny)\s*:\s*$/);
|
|
71
|
+
if (top) {
|
|
72
|
+
// Flush any pending entry into the previous bucket
|
|
73
|
+
if (pending && bucket && pending.pattern)
|
|
74
|
+
cfg[bucket].push(pending);
|
|
75
|
+
pending = null;
|
|
76
|
+
bucket = top[1];
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!bucket)
|
|
80
|
+
continue;
|
|
81
|
+
// List entry start: " - pattern: ..."
|
|
82
|
+
const listStart = stripped.match(/^\s*-\s+pattern\s*:\s*(.+)$/);
|
|
83
|
+
if (listStart) {
|
|
84
|
+
if (pending && pending.pattern)
|
|
85
|
+
cfg[bucket].push(pending);
|
|
86
|
+
pending = { pattern: unquote(listStart[1]) };
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Continuation field on a pending entry
|
|
90
|
+
const cont = stripped.match(/^\s+(reason|addedAt)\s*:\s*(.+)$/);
|
|
91
|
+
if (cont && pending) {
|
|
92
|
+
const key = cont[1];
|
|
93
|
+
pending[key] = unquote(cont[2]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (pending && bucket && pending.pattern)
|
|
97
|
+
cfg[bucket].push(pending);
|
|
98
|
+
return cfg;
|
|
99
|
+
}
|
|
100
|
+
function unquote(s) {
|
|
101
|
+
const t = s.trim();
|
|
102
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
103
|
+
return t.slice(1, -1);
|
|
104
|
+
}
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check whether a finding's matched text is allow-listed in this cwd.
|
|
109
|
+
* Substring match — exact-text patterns are simpler for non-technical
|
|
110
|
+
* users than regex. Returns the matching entry (for audit-trail
|
|
111
|
+
* provenance) or null if no match.
|
|
112
|
+
*/
|
|
113
|
+
export function isAllowed(matchedText, cwd) {
|
|
114
|
+
const cfg = loadPermissions(cwd);
|
|
115
|
+
for (const entry of cfg.allow) {
|
|
116
|
+
if (entry.pattern && matchedText.includes(entry.pattern))
|
|
117
|
+
return entry;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Filter a findings list against the allow-list for a cwd. Returns
|
|
123
|
+
* the findings that survived (i.e., were NOT allow-listed). Used by
|
|
124
|
+
* the trust-scorer / pre-prompt-scan to drop findings that the user
|
|
125
|
+
* has explicitly approved.
|
|
126
|
+
*
|
|
127
|
+
* Side-effect-free: the allow-listed findings are dropped from the
|
|
128
|
+
* caller's perspective. The audit log writer can still record the
|
|
129
|
+
* full finding list IF the caller wants (then mark which were
|
|
130
|
+
* allow-listed). This function is the simple "what should we still
|
|
131
|
+
* block on" filter.
|
|
132
|
+
*/
|
|
133
|
+
export function filterAllowed(findings, cwd) {
|
|
134
|
+
if (findings.length === 0)
|
|
135
|
+
return { surviving: [], allowed: [] };
|
|
136
|
+
const cfg = loadPermissions(cwd);
|
|
137
|
+
if (cfg.allow.length === 0)
|
|
138
|
+
return { surviving: findings, allowed: [] };
|
|
139
|
+
const surviving = [];
|
|
140
|
+
const allowed = [];
|
|
141
|
+
for (const f of findings) {
|
|
142
|
+
const hit = cfg.allow.find((e) => e.pattern && f.matched.includes(e.pattern));
|
|
143
|
+
if (hit)
|
|
144
|
+
allowed.push({ finding: f, entry: hit });
|
|
145
|
+
else
|
|
146
|
+
surviving.push(f);
|
|
147
|
+
}
|
|
148
|
+
return { surviving, allowed };
|
|
149
|
+
}
|
|
150
|
+
// ── Write surface (P18 slice 3) ─────────────────────────────────────────────
|
|
151
|
+
// Shared by the CLI (`codehere permission allow ...`) and the HTTP write
|
|
152
|
+
// endpoint (POST /api/permissions). Single source of truth for the
|
|
153
|
+
// idempotent-add semantics + the YAML serializer.
|
|
154
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
155
|
+
/**
|
|
156
|
+
* Idempotent add — if the pattern already exists in the bucket, returns
|
|
157
|
+
* status='already_present' with the existing entry. Otherwise appends +
|
|
158
|
+
* writes the file. Writes via Write tool semantics (overwrites the
|
|
159
|
+
* whole YAML each time; entries are O(N) so re-serialization is cheap).
|
|
160
|
+
*/
|
|
161
|
+
export function addPermissionEntry(input) {
|
|
162
|
+
const bucket = input.bucket ?? 'allow';
|
|
163
|
+
const cfg = loadPermissions(input.cwd);
|
|
164
|
+
const list = cfg[bucket];
|
|
165
|
+
const existing = list.find((e) => e.pattern === input.pattern);
|
|
166
|
+
if (existing) {
|
|
167
|
+
return {
|
|
168
|
+
status: 'already_present',
|
|
169
|
+
bucket,
|
|
170
|
+
entry: existing,
|
|
171
|
+
filePath: join(input.cwd, '.codehere', 'permissions.yaml'),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const entry = {
|
|
175
|
+
pattern: input.pattern,
|
|
176
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
177
|
+
addedAt: new Date().toISOString(),
|
|
178
|
+
};
|
|
179
|
+
cfg[bucket].push(entry);
|
|
180
|
+
const filePath = writePermissionsConfig(input.cwd, cfg);
|
|
181
|
+
return { status: 'added', bucket, entry, filePath };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Idempotent remove — drops every entry whose pattern matches across
|
|
185
|
+
* BOTH buckets. Returns count of removed entries.
|
|
186
|
+
*/
|
|
187
|
+
export function removePermissionEntry(cwd, pattern) {
|
|
188
|
+
const cfg = loadPermissions(cwd);
|
|
189
|
+
const before = cfg.allow.length + cfg.deny.length;
|
|
190
|
+
cfg.allow = cfg.allow.filter((e) => e.pattern !== pattern);
|
|
191
|
+
cfg.deny = cfg.deny.filter((e) => e.pattern !== pattern);
|
|
192
|
+
const after = cfg.allow.length + cfg.deny.length;
|
|
193
|
+
const removed = before - after;
|
|
194
|
+
if (removed > 0)
|
|
195
|
+
writePermissionsConfig(cwd, cfg);
|
|
196
|
+
return { removed, filePath: join(cwd, '.codehere', 'permissions.yaml') };
|
|
197
|
+
}
|
|
198
|
+
function writePermissionsConfig(cwd, cfg) {
|
|
199
|
+
const dir = join(cwd, '.codehere');
|
|
200
|
+
if (!existsSync(dir))
|
|
201
|
+
mkdirSync(dir, { recursive: true });
|
|
202
|
+
const filePath = join(dir, 'permissions.yaml');
|
|
203
|
+
writeFileSync(filePath, serializePermissionsYaml(cfg), 'utf-8');
|
|
204
|
+
return filePath;
|
|
205
|
+
}
|
|
206
|
+
export function serializePermissionsYaml(cfg) {
|
|
207
|
+
const out = [];
|
|
208
|
+
out.push('# codehere permissions — per-folder allow/deny list');
|
|
209
|
+
out.push('# See: codehere permission --help');
|
|
210
|
+
out.push('');
|
|
211
|
+
for (const bucket of ['allow', 'deny']) {
|
|
212
|
+
out.push(`${bucket}:`);
|
|
213
|
+
if (cfg[bucket].length === 0) {
|
|
214
|
+
out.push(' []');
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
for (const e of cfg[bucket]) {
|
|
218
|
+
out.push(` - pattern: ${quoteYaml(e.pattern)}`);
|
|
219
|
+
if (e.reason)
|
|
220
|
+
out.push(` reason: ${quoteYaml(e.reason)}`);
|
|
221
|
+
if (e.addedAt)
|
|
222
|
+
out.push(` addedAt: ${quoteYaml(e.addedAt)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
out.push('');
|
|
226
|
+
}
|
|
227
|
+
return out.join('\n');
|
|
228
|
+
}
|
|
229
|
+
function quoteYaml(s) {
|
|
230
|
+
if (s.includes('"') && !s.includes("'"))
|
|
231
|
+
return `'${s}'`;
|
|
232
|
+
return `"${s.replace(/"/g, '\\"')}"`;
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=permissions-config.js.map
|
|
@@ -27,18 +27,13 @@
|
|
|
27
27
|
* Iter 13 — built after v0.5.0-alpha.4 ship. Previously the only way new
|
|
28
28
|
* sessions reached thread.jsonl was to re-run migration; this closes the gap.
|
|
29
29
|
*/
|
|
30
|
-
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
30
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
|
|
31
31
|
import { homedir } from 'os';
|
|
32
32
|
import { join } from 'path';
|
|
33
33
|
import { createHash } from 'crypto';
|
|
34
34
|
const PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
|
|
35
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
|
-
];
|
|
36
|
+
const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
|
|
42
37
|
function isTempDir(cwd) {
|
|
43
38
|
if (!cwd)
|
|
44
39
|
return false;
|
|
@@ -84,19 +79,331 @@ function appendThreadEntry(slug, entry) {
|
|
|
84
79
|
return false;
|
|
85
80
|
}
|
|
86
81
|
}
|
|
87
|
-
/** Append a project-meta update line (used by Title agent to set the title).
|
|
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. */
|
|
88
86
|
function appendMetaUpdate(slug, partial) {
|
|
89
87
|
try {
|
|
90
88
|
const dir = ensureProjectDir(slug);
|
|
91
89
|
const file = join(dir, 'thread.jsonl');
|
|
92
90
|
if (!existsSync(file))
|
|
93
|
-
return; // can't update what doesn't exist
|
|
91
|
+
return false; // can't update what doesn't exist
|
|
94
92
|
appendFileSync(file, JSON.stringify({ _meta: 'project-update', slug, ...partial, updatedAt: Date.now() }) + '\n', 'utf-8');
|
|
93
|
+
return true;
|
|
95
94
|
}
|
|
96
95
|
catch {
|
|
97
|
-
|
|
96
|
+
return false;
|
|
98
97
|
}
|
|
99
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
|
+
}
|
|
100
407
|
/**
|
|
101
408
|
* Mirror a spawn event to the project thread. Skips temp-dir cwds (matches
|
|
102
409
|
* migration defaults) so test-artifact dirs don't pollute the project list.
|
|
@@ -120,21 +427,36 @@ export function mirrorSpawnedToThread(rec) {
|
|
|
120
427
|
startedAt: rec.startedAt,
|
|
121
428
|
status: 'running',
|
|
122
429
|
});
|
|
123
|
-
if (seeded
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
454
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
}
|
|
455
|
+
catch {
|
|
456
|
+
// best-effort — cwd fallback already seeded
|
|
457
|
+
}
|
|
458
|
+
})();
|
|
459
|
+
}
|
|
138
460
|
}
|
|
139
461
|
}
|
|
140
462
|
/**
|
|
@@ -47,14 +47,36 @@ export function listProjects(options) {
|
|
|
47
47
|
});
|
|
48
48
|
const summaries = [];
|
|
49
49
|
for (const slug of slugs) {
|
|
50
|
-
|
|
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 });
|
|
51
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
|
+
}
|
|
52
72
|
summaries.push({
|
|
53
73
|
slug: thread.slug,
|
|
54
74
|
cwd: thread.cwd,
|
|
55
75
|
title: thread.title,
|
|
56
76
|
taskCount: thread.taskCount,
|
|
57
77
|
lastActivityAt: thread.lastActivityAt,
|
|
78
|
+
lastAgent,
|
|
79
|
+
recentAgents,
|
|
58
80
|
});
|
|
59
81
|
}
|
|
60
82
|
}
|