codehere 0.8.0 → 1.0.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 +22 -13
- package/dist/application/services/verdict-service.js +154 -0
- package/dist/application/use-cases/assess-cross-vendor.js +126 -0
- package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
- package/dist/application/use-cases/export-aat.js +107 -0
- package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
- package/dist/application/use-cases/generate-final-report.js +140 -15
- package/dist/application/use-cases/generate-narration.js +5 -3
- package/dist/application/use-cases/generate-thread-title.js +5 -2
- package/dist/application/use-cases/plan-auto-review.js +57 -0
- package/dist/application/use-cases/reconcile-audit.js +100 -0
- package/dist/application/use-cases/rerun-tests.js +226 -0
- package/dist/application/use-cases/search-sessions.js +70 -0
- package/dist/application/use-cases/should-auto-review.js +71 -0
- package/dist/application/use-cases/thread-export.js +90 -0
- package/dist/application/use-cases/verify-claim.js +187 -5
- package/dist/domain/audit.js +11 -0
- package/dist/domain/services/codebase-detector.js +0 -6
- package/dist/domain/task.js +91 -0
- package/dist/error-handler.js +3 -115
- package/dist/index.js +269 -25
- package/dist/infrastructure/completion/bash-completion.js +7 -3
- package/dist/infrastructure/completion/fish-completion.js +7 -12
- package/dist/infrastructure/completion/zsh-completion.js +7 -12
- package/dist/infrastructure/config/config-manager.js +4 -3
- package/dist/infrastructure/config/product-context.js +1 -19
- package/dist/infrastructure/context/adapters/figma-source.js +37 -2
- package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
- package/dist/infrastructure/context/github-api.js +110 -0
- package/dist/infrastructure/context/github-device-flow.js +65 -0
- package/dist/infrastructure/context/oauth-store.js +5 -2
- package/dist/infrastructure/context/registry-bootstrap.js +0 -4
- package/dist/infrastructure/cost/budget-config.js +3 -2
- package/dist/infrastructure/cost/cost-router.js +16 -23
- package/dist/infrastructure/cost/spend-aggregator.js +90 -15
- package/dist/infrastructure/cost/team-ledger.js +355 -0
- package/dist/infrastructure/discovery/discovery-log.js +85 -0
- package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
- package/dist/infrastructure/hooks/hook-mode.js +20 -4
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
- package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
- package/dist/infrastructure/observability/execution-tracer.js +1 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
- package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
- package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
- package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
- package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
- package/dist/infrastructure/orchestration/context-handoff.js +32 -14
- package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
- package/dist/infrastructure/orchestration/failover-config.js +0 -10
- package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
- package/dist/infrastructure/orchestration/graphify-context.js +14 -59
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
- package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
- package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
- package/dist/infrastructure/orchestration/session-manager.js +593 -94
- package/dist/infrastructure/orchestration/session-persistence.js +177 -41
- package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
- package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
- package/dist/infrastructure/protocols/mcp-tools.js +401 -0
- package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
- package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
- package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
- package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
- package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
- package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
- package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
- package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
- package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
- package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
- package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
- package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
- package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
- package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
- package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
- package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
- package/dist/infrastructure/protocols/ui/styles.js +2147 -0
- package/dist/infrastructure/protocols/ui/tokens.js +96 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
- package/dist/infrastructure/protocols/verify-page.js +849 -155
- package/dist/infrastructure/security/fix-code.js +144 -0
- package/dist/infrastructure/security/fix-secret.js +89 -0
- package/dist/infrastructure/security/permissions-config.js +3 -2
- package/dist/infrastructure/security/scan-types.js +10 -0
- package/dist/infrastructure/security/ship-scan.js +195 -0
- package/dist/infrastructure/security/trust-scorer.js +70 -3
- package/dist/infrastructure/security/vibe-checks.js +99 -0
- package/dist/infrastructure/storage/atomic-write.js +86 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
- package/dist/infrastructure/storage/project-thread-reader.js +261 -24
- package/dist/monitoring.js +2 -2
- package/dist/presentation/cli/commands/audit-command.js +79 -4
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/check-command.js +96 -0
- package/dist/presentation/cli/commands/context-source-command.js +20 -1
- package/dist/presentation/cli/commands/help-command.js +5 -5
- package/dist/presentation/cli/commands/hook-command.js +5 -5
- package/dist/presentation/cli/commands/init-command.js +26 -14
- package/dist/presentation/cli/commands/memory-command.js +28 -28
- package/dist/presentation/cli/commands/permission-command.js +1 -12
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/protocols-command.js +4 -4
- package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
- package/dist/presentation/cli/commands/report-command.js +48 -32
- package/dist/presentation/cli/commands/scan-command.js +52 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
- package/dist/presentation/cli/commands/search-command.js +51 -0
- package/dist/presentation/cli/commands/setup-command.js +31 -8
- package/dist/presentation/cli/commands/status-command.js +39 -86
- package/dist/presentation/cli/commands/team-command.js +256 -0
- package/dist/presentation/cli/commands/thread-command.js +87 -5
- package/dist/presentation/cli/commands/trust-command.js +53 -31
- package/dist/presentation/cli/commands/verify-command.js +74 -9
- package/dist/presentation/cli/error-display.js +0 -8
- package/dist/presentation/cli/repl.js +15 -7
- package/dist/presentation/cli/task-deliverable.js +36 -10
- package/dist/testing/replay-adapter.js +0 -19
- package/dist/ui.js +51 -239
- package/dist/utils/version.js +0 -6
- package/package.json +30 -10
- package/dist/domain/entities/task.js +0 -61
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/task-repository.js +0 -206
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a file so a reader never sees it half-written.
|
|
3
|
+
*
|
|
4
|
+
* Why (measured 2026-08-08, not assumed): `writeFileSync` is open(O_TRUNC) then
|
|
5
|
+
* write. SIGKILL landing between the two leaves a truncated, unparseable file.
|
|
6
|
+
* Induced deliberately — 40 attempts at a ~290KB payload, **15 came back torn**.
|
|
7
|
+
* The window shrinks with file size but never closes, and codehere's state files
|
|
8
|
+
* only grow.
|
|
9
|
+
*
|
|
10
|
+
* The failure this prevents is not "the write was lost" — losing the write is
|
|
11
|
+
* survivable, the caller can write again. It is "the file that survived is
|
|
12
|
+
* garbage": a corrupt permissions list, an unreadable budget ceiling, an OAuth
|
|
13
|
+
* store that no longer parses. `rename(2)` is atomic on POSIX, so a reader sees
|
|
14
|
+
* either the whole old file or the whole new one, never a splice of both.
|
|
15
|
+
*
|
|
16
|
+
* NOT what this gives you: durability across power loss. That needs fsync on the
|
|
17
|
+
* file AND its directory, which costs a real syscall on every write. The failure
|
|
18
|
+
* mode there is different and much less bad — you lose the write, you do not get
|
|
19
|
+
* a corrupt file — so it is deliberately not paid for here. Say so rather than
|
|
20
|
+
* implying this makes writes crash-proof.
|
|
21
|
+
*
|
|
22
|
+
* Appends are NOT routed through this and do not need to be. `appendFileSync`
|
|
23
|
+
* opens O_APPEND, and each write is atomic with respect to the file offset:
|
|
24
|
+
* measured with 6 concurrent writers × 300 records, and again at 90KB records,
|
|
25
|
+
* with zero interleaving or loss both times. The JSONL chain and session logs
|
|
26
|
+
* were never at risk from this, and an earlier audit that counted appends
|
|
27
|
+
* alongside whole-file writes overstated the problem.
|
|
28
|
+
*/
|
|
29
|
+
import { writeFileSync, renameSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
30
|
+
import { dirname, basename, join } from 'node:path';
|
|
31
|
+
/** A temp older than this was orphaned by a crash, not left by a live write. */
|
|
32
|
+
const STALE_TEMP_MS = 60_000;
|
|
33
|
+
/**
|
|
34
|
+
* Sweep temps this file's own writes orphaned. SIGKILL cannot run a catch block,
|
|
35
|
+
* so a hard crash mid-write always leaves one behind — measured at 16 after 40
|
|
36
|
+
* kills. Without this, trading a corrupt file for permanent litter in the user's
|
|
37
|
+
* state dir just moves the problem: a stray `.tmp` next to `permissions.yaml`
|
|
38
|
+
* reads as corruption to anyone opening the directory.
|
|
39
|
+
*
|
|
40
|
+
* Scoped to the target's own basename so it can never touch another file's
|
|
41
|
+
* in-flight temp, and age-gated so it can never race a concurrent writer.
|
|
42
|
+
*/
|
|
43
|
+
function sweepStaleTemps(path) {
|
|
44
|
+
const dir = dirname(path);
|
|
45
|
+
const prefix = `${basename(path)}.`;
|
|
46
|
+
try {
|
|
47
|
+
for (const name of readdirSync(dir)) {
|
|
48
|
+
if (!name.startsWith(prefix) || !name.endsWith('.tmp'))
|
|
49
|
+
continue;
|
|
50
|
+
const p = join(dir, name);
|
|
51
|
+
try {
|
|
52
|
+
if (Date.now() - statSync(p).mtimeMs > STALE_TEMP_MS)
|
|
53
|
+
unlinkSync(p);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* vanished under us, or not ours to remove — either way, leave it */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* unreadable dir · sweeping is hygiene, never a reason to fail the write */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function writeFileAtomic(path, data, opts) {
|
|
65
|
+
// pid + time keeps two concurrent writers of the SAME path from clobbering
|
|
66
|
+
// each other's temp file; the rename still decides who wins, and either whole
|
|
67
|
+
// file is valid.
|
|
68
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(tmp, data, opts?.mode !== undefined ? { mode: opts.mode } : undefined);
|
|
71
|
+
renameSync(tmp, path);
|
|
72
|
+
sweepStaleTemps(path);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// Never leave the temp behind on a failed write — it would accumulate in the
|
|
76
|
+
// user's state dir forever and look like corruption to anyone reading it.
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(tmp);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* the temp never existed, or is already gone */
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=atomic-write.js.map
|
|
@@ -31,6 +31,8 @@ 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
|
+
import { execFileSync } from 'child_process';
|
|
35
|
+
import { normalizeRemote } from './project-thread-reader.js';
|
|
34
36
|
const PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
35
37
|
/** Match migration module's defaults. Same regex set kept in sync. */
|
|
36
38
|
const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
|
|
@@ -44,6 +46,27 @@ function slugForCwd(cwd) {
|
|
|
44
46
|
return 'unknown';
|
|
45
47
|
return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
46
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Detect the git remote for a cwd → normalized `host/org/repo` (project
|
|
51
|
+
* identity). Best-effort + fast: a short-timeout `git remote get-url origin`,
|
|
52
|
+
* silent on not-a-repo / no-remote / git-missing. Called at most once per
|
|
53
|
+
* project (write-time seed + the daemon-start backfill), never on a read poll.
|
|
54
|
+
*/
|
|
55
|
+
function detectRemote(cwd) {
|
|
56
|
+
if (!cwd)
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], {
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
timeout: 2000,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
63
|
+
});
|
|
64
|
+
return normalizeRemote(out.trim());
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
47
70
|
function ensureProjectDir(slug) {
|
|
48
71
|
const dir = join(PROJECTS_DIR, slug);
|
|
49
72
|
if (!existsSync(dir))
|
|
@@ -224,7 +247,7 @@ export async function backfillProjectTitles() {
|
|
|
224
247
|
if (ok) {
|
|
225
248
|
rewritten++;
|
|
226
249
|
if (process.env.CODEHERE_DEBUG === '1') {
|
|
227
|
-
console.error(`[backfill] ${slug}: "${latestTitle.slice(0, 40)}"
|
|
250
|
+
console.error(`[backfill] ${slug}: "${latestTitle.slice(0, 40)}" › "${fallback}"`);
|
|
228
251
|
}
|
|
229
252
|
}
|
|
230
253
|
}
|
|
@@ -234,6 +257,79 @@ export async function backfillProjectTitles() {
|
|
|
234
257
|
}
|
|
235
258
|
return { scanned, rewritten };
|
|
236
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* One-time backfill of the git remote into _meta for projects that predate
|
|
262
|
+
* write-time detection (or whose seed-time detection found none). Idempotent:
|
|
263
|
+
* any project carrying a non-null remote OR a `_remoteChecked` marker is
|
|
264
|
+
* skipped, so this runs the git probe at most once per project across the
|
|
265
|
+
* daemon's lifetime. Best-effort; per-project failures continue.
|
|
266
|
+
*
|
|
267
|
+
* Wired at daemon start alongside backfillProjectTitles. Returns the count of
|
|
268
|
+
* projects for which a real remote was detected.
|
|
269
|
+
*/
|
|
270
|
+
export async function backfillProjectRemotes() {
|
|
271
|
+
let scanned = 0;
|
|
272
|
+
let detected = 0;
|
|
273
|
+
if (!existsSync(PROJECTS_DIR))
|
|
274
|
+
return { scanned, detected };
|
|
275
|
+
let entries;
|
|
276
|
+
try {
|
|
277
|
+
const { readdirSync } = await import('fs');
|
|
278
|
+
entries = readdirSync(PROJECTS_DIR);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { scanned, detected };
|
|
282
|
+
}
|
|
283
|
+
for (const slug of entries) {
|
|
284
|
+
if (!/^[a-f0-9]{12}$|^unknown$/.test(slug))
|
|
285
|
+
continue;
|
|
286
|
+
scanned++;
|
|
287
|
+
try {
|
|
288
|
+
const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
|
|
289
|
+
if (!existsSync(file))
|
|
290
|
+
continue;
|
|
291
|
+
const content = readFileSync(file, 'utf-8');
|
|
292
|
+
const lines = content.split('\n').filter((l) => l.trim());
|
|
293
|
+
let alreadyChecked = false;
|
|
294
|
+
let haveRemote = false;
|
|
295
|
+
let latestCwd = null;
|
|
296
|
+
for (const line of lines) {
|
|
297
|
+
try {
|
|
298
|
+
const rec = JSON.parse(line);
|
|
299
|
+
if (rec._meta === 'project' || rec._meta === 'project-update') {
|
|
300
|
+
if (rec._remoteChecked === true)
|
|
301
|
+
alreadyChecked = true;
|
|
302
|
+
if (typeof rec.remote === 'string' && rec.remote)
|
|
303
|
+
haveRemote = true;
|
|
304
|
+
if (typeof rec.cwd === 'string' && rec.cwd && !latestCwd)
|
|
305
|
+
latestCwd = rec.cwd;
|
|
306
|
+
}
|
|
307
|
+
if (rec.type === 'task' && typeof rec.cwd === 'string' && rec.cwd)
|
|
308
|
+
latestCwd = rec.cwd;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
/* skip malformed line */
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (alreadyChecked || haveRemote)
|
|
315
|
+
continue; // idempotent — already settled
|
|
316
|
+
const remote = detectRemote(latestCwd);
|
|
317
|
+
// Stamp _remoteChecked even on a null result so we never re-probe a
|
|
318
|
+
// remote-less project. The reader ignores a null remote (keeps it null).
|
|
319
|
+
const ok = appendMetaUpdate(slug, { remote: remote ?? null, _remoteChecked: true });
|
|
320
|
+
if (ok && remote) {
|
|
321
|
+
detected++;
|
|
322
|
+
if (process.env.CODEHERE_DEBUG === '1') {
|
|
323
|
+
console.error(`[backfill-remote] ${slug}: ${remote}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
/* per-project failure → continue */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { scanned, detected };
|
|
332
|
+
}
|
|
237
333
|
/**
|
|
238
334
|
* One-shot reconciliation for stale-running tasks (Bug: project-thread.jsonl
|
|
239
335
|
* entries can stay in `status: 'running'` forever when the daemon dies
|
|
@@ -330,7 +426,7 @@ export async function reconcileStaleProjectTasks(options) {
|
|
|
330
426
|
reconciled++;
|
|
331
427
|
if (process.env.CODEHERE_DEBUG === '1') {
|
|
332
428
|
const ageMin = Math.round((now - state.startedAt) / 60000);
|
|
333
|
-
console.error(`[reconcile] ${slug}/${state.sessionId.slice(0, 8)} stale running for ${ageMin}m
|
|
429
|
+
console.error(`[reconcile] ${slug}/${state.sessionId.slice(0, 8)} stale running for ${ageMin}m › crashed`);
|
|
334
430
|
}
|
|
335
431
|
}
|
|
336
432
|
}
|
|
@@ -404,6 +500,29 @@ export function renameProject(slug, newTitle) {
|
|
|
404
500
|
}
|
|
405
501
|
return { ok: true };
|
|
406
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Archive (soft-delete) or restore a task. Append-only and audit-safe: writes an
|
|
505
|
+
* `{_meta:'archived', sessionId, archived}` event to the thread (latest wins).
|
|
506
|
+
* The task record is NEVER deleted — the proof stays; the room just hides it from
|
|
507
|
+
* the active list. Restore = the same event with archived:false.
|
|
508
|
+
*/
|
|
509
|
+
export function setTaskArchived(slug, sessionId, archived) {
|
|
510
|
+
if (!/^([a-f0-9]{12}|unknown)$/.test(slug))
|
|
511
|
+
return { ok: false, error: 'Invalid project slug' };
|
|
512
|
+
if (!sessionId || typeof sessionId !== 'string' || sessionId.length > 200 || /[\n\r]/.test(sessionId)) {
|
|
513
|
+
return { ok: false, error: 'Invalid sessionId' };
|
|
514
|
+
}
|
|
515
|
+
const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
|
|
516
|
+
if (!existsSync(file))
|
|
517
|
+
return { ok: false, error: 'Project not found' };
|
|
518
|
+
try {
|
|
519
|
+
appendFileSync(file, JSON.stringify({ _meta: 'archived', slug, sessionId, archived: !!archived, updatedAt: Date.now() }) + '\n', 'utf-8');
|
|
520
|
+
return { ok: true };
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return { ok: false, error: 'Write failed' };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
407
526
|
/**
|
|
408
527
|
* Mirror a spawn event to the project thread. Skips temp-dir cwds (matches
|
|
409
528
|
* migration defaults) so test-artifact dirs don't pollute the project list.
|
|
@@ -426,6 +545,8 @@ export function mirrorSpawnedToThread(rec) {
|
|
|
426
545
|
cwd: rec.cwd,
|
|
427
546
|
startedAt: rec.startedAt,
|
|
428
547
|
status: 'running',
|
|
548
|
+
...(rec.batchId ? { batchId: rec.batchId } : {}),
|
|
549
|
+
...(rec.parentSessionId ? { parentSessionId: rec.parentSessionId } : {}),
|
|
429
550
|
});
|
|
430
551
|
if (seeded) {
|
|
431
552
|
// Seed an immediate cwd-derived title so the sidebar never shows a blank
|
|
@@ -436,6 +557,13 @@ export function mirrorSpawnedToThread(rec) {
|
|
|
436
557
|
const fallback = titleFromCwd(rec.cwd);
|
|
437
558
|
appendMetaUpdate(slug, { title: fallback, cwd: rec.cwd });
|
|
438
559
|
}
|
|
560
|
+
// Detect + cache the git remote ONCE at project creation. Project identity
|
|
561
|
+
// (and clone-collapse in listProjects) reads this from _meta — no git
|
|
562
|
+
// shell-out on the hot read path. Only written when a remote is found;
|
|
563
|
+
// remote-less / non-repo projects are settled by the daemon-start backfill.
|
|
564
|
+
const remote = detectRemote(rec.cwd);
|
|
565
|
+
if (remote)
|
|
566
|
+
appendMetaUpdate(slug, { remote, _remoteChecked: true });
|
|
439
567
|
if (process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
|
|
440
568
|
// Fire Title agent in background; do not block the spawn path. Lazy-import
|
|
441
569
|
// to avoid loading the LLM module on every spawn when not needed.
|
|
@@ -478,6 +606,25 @@ export function mirrorCompletedToThread(rec) {
|
|
|
478
606
|
artifacts: rec.artifacts,
|
|
479
607
|
});
|
|
480
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* Persist a generated Final Report summary to the project thread. The report is
|
|
611
|
+
* produced ASYNC after the session exits, so mirrorCompletedToThread already ran
|
|
612
|
+
* without it — this second append fills finalReport in. The reader merges task
|
|
613
|
+
* records by sessionId field-by-field, so this partial entry layers onto the
|
|
614
|
+
* existing completion record. Without it the summary lives only in the in-memory
|
|
615
|
+
* cache + sessions.jsonl: the conversation view loses it on a daemon restart, and
|
|
616
|
+
* a cross-agent hand-off reads "(no summary recorded)" instead of the real work.
|
|
617
|
+
*/
|
|
618
|
+
export function mirrorFinalReportToThread(rec) {
|
|
619
|
+
if (isTempDir(rec.cwd))
|
|
620
|
+
return;
|
|
621
|
+
const slug = slugForCwd(rec.cwd);
|
|
622
|
+
appendThreadEntry(slug, {
|
|
623
|
+
type: 'task',
|
|
624
|
+
sessionId: rec.sessionId,
|
|
625
|
+
finalReport: { summary: rec.summary, ...(rec.source ? { source: rec.source } : {}) },
|
|
626
|
+
});
|
|
627
|
+
}
|
|
481
628
|
/** Label map for raw error codes → user-facing text. */
|
|
482
629
|
const FAILOVER_LABELS = {
|
|
483
630
|
rate_limit: 'hit a rate limit',
|
|
@@ -496,7 +643,7 @@ export function mirrorFailoverToThread(rec) {
|
|
|
496
643
|
type: 'task',
|
|
497
644
|
sessionId: rec.sessionId,
|
|
498
645
|
agent: 'system',
|
|
499
|
-
prompt:
|
|
646
|
+
prompt: `Switched from ${fromLabel} to ${toLabel} (${reasonLabel}) — warm restart with context, not a state transfer`,
|
|
500
647
|
status: 'completed',
|
|
501
648
|
startedAt: Date.now(),
|
|
502
649
|
completedAt: Date.now(),
|
|
@@ -21,7 +21,123 @@
|
|
|
21
21
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
22
22
|
import { join } from 'path';
|
|
23
23
|
import { homedir } from 'os';
|
|
24
|
+
import { estimateCostUsd } from '../cost/spend-aggregator.js';
|
|
24
25
|
const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
26
|
+
/**
|
|
27
|
+
* Normalize a git remote URL to a stable identity key `host/org/repo`:
|
|
28
|
+
* - strips scheme (https/http/ssh/git) + any user@ info
|
|
29
|
+
* - handles scp-like `git@host:org/repo`
|
|
30
|
+
* - drops a trailing `.git` and trailing slashes
|
|
31
|
+
* - lowercases the host (path case preserved)
|
|
32
|
+
* Returns null for empty/garbage input. Shared by the reader (identity grouping)
|
|
33
|
+
* and the forward-mirror (write-time detection) so both compute the same id.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeRemote(url) {
|
|
36
|
+
if (!url)
|
|
37
|
+
return null;
|
|
38
|
+
let s = String(url).trim();
|
|
39
|
+
if (!s)
|
|
40
|
+
return null;
|
|
41
|
+
const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/); // git@host:org/repo(.git)
|
|
42
|
+
if (scp) {
|
|
43
|
+
s = scp[1] + '/' + scp[2];
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); // scheme://
|
|
47
|
+
s = s.replace(/^[^@/]+@/, ''); // user@ (ssh://user@host/…)
|
|
48
|
+
}
|
|
49
|
+
s = s.replace(/\.git$/i, '').replace(/\/+$/, '');
|
|
50
|
+
if (!s)
|
|
51
|
+
return null;
|
|
52
|
+
const slash = s.indexOf('/');
|
|
53
|
+
if (slash === -1)
|
|
54
|
+
return s.toLowerCase();
|
|
55
|
+
return s.slice(0, slash).toLowerCase() + s.slice(slash);
|
|
56
|
+
}
|
|
57
|
+
/** True for hosts a clone must never reach — loopback, link-local (incl. cloud
|
|
58
|
+
* metadata 169.254.169.254), and RFC-1918 private ranges. `host` is the
|
|
59
|
+
* `host[:port]` segment from a normalized remote. */
|
|
60
|
+
function isBlockedCloneHost(host) {
|
|
61
|
+
const h = String(host ?? '')
|
|
62
|
+
.split(':')[0]
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
if (!h)
|
|
65
|
+
return true;
|
|
66
|
+
if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.endsWith('.localhost'))
|
|
67
|
+
return true;
|
|
68
|
+
if (/^127\./.test(h))
|
|
69
|
+
return true; // loopback
|
|
70
|
+
if (/^169\.254\./.test(h))
|
|
71
|
+
return true; // link-local + cloud metadata
|
|
72
|
+
if (/^10\./.test(h))
|
|
73
|
+
return true; // RFC-1918
|
|
74
|
+
if (/^192\.168\./.test(h))
|
|
75
|
+
return true; // RFC-1918
|
|
76
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
|
|
77
|
+
return true; // RFC-1918
|
|
78
|
+
if (/^0\./.test(h))
|
|
79
|
+
return true; // "this" network
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse a user-pasted GitHub (or any git) URL into a cloneable URL + a
|
|
84
|
+
* predictable local target directory. Pure (no IO) so the clone route can be
|
|
85
|
+
* tested without a network or filesystem. Accepts the forms a developer
|
|
86
|
+
* actually pastes: full https, ssh (git@host:org/repo), bare host/org/repo,
|
|
87
|
+
* and the `org/repo` GitHub shorthand. Rejects anything without a real
|
|
88
|
+
* host/org/repo shape (or with whitespace — a git URL never has any).
|
|
89
|
+
*
|
|
90
|
+
* The target dir is `<baseDir>/<repo>` — visible + predictable, NOT buried in
|
|
91
|
+
* a hidden dir, so the user can `cd` to it. The caller decides what to do when
|
|
92
|
+
* the dir already exists (we open it rather than re-clone — see the route).
|
|
93
|
+
*/
|
|
94
|
+
export function parseGitHubCloneTarget(rawUrl, baseDir) {
|
|
95
|
+
const raw = String(rawUrl ?? '').trim();
|
|
96
|
+
if (!raw)
|
|
97
|
+
return { ok: false, error: 'url_required' };
|
|
98
|
+
if (/\s/.test(raw))
|
|
99
|
+
return { ok: false, error: 'invalid_url' };
|
|
100
|
+
let cloneUrl;
|
|
101
|
+
if (/^[^@/]+@[^:/]+:.+/.test(raw)) {
|
|
102
|
+
cloneUrl = raw; // git@host:org/repo(.git)
|
|
103
|
+
}
|
|
104
|
+
else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) {
|
|
105
|
+
cloneUrl = raw; // already has a scheme (https://, ssh://, …)
|
|
106
|
+
}
|
|
107
|
+
else if (/^[\w.-]+\.[\w.-]+\/.+/.test(raw)) {
|
|
108
|
+
cloneUrl = 'https://' + raw; // host.tld/org/repo (no scheme)
|
|
109
|
+
}
|
|
110
|
+
else if (/^[\w.-]+\/[\w.-]+$/.test(raw)) {
|
|
111
|
+
cloneUrl = 'https://github.com/' + raw; // org/repo GitHub shorthand
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
return { ok: false, error: 'invalid_url' };
|
|
115
|
+
}
|
|
116
|
+
// Scheme allowlist — only https + ssh reach `git clone`. Rejects file:// (local
|
|
117
|
+
// file read), http:// (plaintext + the cloud-metadata / localhost SSRF vector),
|
|
118
|
+
// and every other transport. The scp form (git@host:…) carries no scheme and is
|
|
119
|
+
// allowed. This is the SSRF/file:// surface a pre-push review flagged.
|
|
120
|
+
const scheme = cloneUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
|
|
121
|
+
if (scheme && !['https', 'ssh'].includes(scheme[1].toLowerCase()))
|
|
122
|
+
return { ok: false, error: 'invalid_url' };
|
|
123
|
+
const norm = normalizeRemote(cloneUrl); // host/org/repo, lowercased host
|
|
124
|
+
if (!norm)
|
|
125
|
+
return { ok: false, error: 'invalid_url' };
|
|
126
|
+
const parts = norm.split('/');
|
|
127
|
+
if (parts.length < 3)
|
|
128
|
+
return { ok: false, error: 'invalid_url' }; // need host + org + repo
|
|
129
|
+
// Internal-host denylist — don't let a clone target loopback / link-local /
|
|
130
|
+
// RFC-1918 (the rest of the SSRF surface: metadata endpoints, internal probing).
|
|
131
|
+
if (isBlockedCloneHost(parts[0]))
|
|
132
|
+
return { ok: false, error: 'invalid_url' };
|
|
133
|
+
const repo = (parts[parts.length - 1] || '').replace(/[^\w.-]/g, '');
|
|
134
|
+
// Reject '.'/'..'/empty — '.' and '..' survive the char-class strip and would
|
|
135
|
+
// traverse out of baseDir (join(baseDir, '..') = its parent). The clone dir
|
|
136
|
+
// must be a fresh leaf inside baseDir, nothing else.
|
|
137
|
+
if (!repo || repo === '.' || repo === '..')
|
|
138
|
+
return { ok: false, error: 'invalid_url' };
|
|
139
|
+
return { ok: true, cloneUrl, repo, dir: join(baseDir, repo) };
|
|
140
|
+
}
|
|
25
141
|
/** List all known projects with summary stats. Cheap; no full task content. */
|
|
26
142
|
export function listProjects(options) {
|
|
27
143
|
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
@@ -45,23 +161,59 @@ export function listProjects(options) {
|
|
|
45
161
|
return false;
|
|
46
162
|
}
|
|
47
163
|
});
|
|
48
|
-
const
|
|
164
|
+
const perSlug = [];
|
|
49
165
|
for (const slug of slugs) {
|
|
50
166
|
// Pattern 4 — sidebar presence dots need agent attribution per project.
|
|
51
|
-
// Pull the full task list
|
|
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.
|
|
167
|
+
// Pull the full task list so we can compute agents + per-project cost.
|
|
55
168
|
const thread = readProjectThread(slug, { projectsDir: root });
|
|
56
|
-
if (thread)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
169
|
+
if (!thread)
|
|
170
|
+
continue;
|
|
171
|
+
const agentChrono = thread.tasks
|
|
172
|
+
.filter((t) => typeof t.agent === 'string' && t.agent)
|
|
173
|
+
.sort((a, b) => b.startedAt - a.startedAt) // most-recent first
|
|
174
|
+
.map((t) => t.agent);
|
|
175
|
+
// Per-project spend — same estimator the per-task /api/tasks cost uses.
|
|
176
|
+
let totalCostCents = 0;
|
|
177
|
+
for (const t of thread.tasks) {
|
|
178
|
+
const u = t.artifacts?.usage;
|
|
179
|
+
if (!u)
|
|
180
|
+
continue;
|
|
181
|
+
totalCostCents += estimateCostUsd(t.agent, u) * 100;
|
|
182
|
+
}
|
|
183
|
+
perSlug.push({
|
|
184
|
+
slug: thread.slug,
|
|
185
|
+
id: normalizeRemote(thread.remote) ?? 'local:' + thread.slug,
|
|
186
|
+
remote: thread.remote,
|
|
187
|
+
cwd: thread.cwd,
|
|
188
|
+
title: thread.title,
|
|
189
|
+
taskCount: thread.taskCount,
|
|
190
|
+
totalCostCents,
|
|
191
|
+
lastActivityAt: thread.lastActivityAt,
|
|
192
|
+
agentChrono,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Second pass — merge slugs that resolve to the same project id (clones of
|
|
196
|
+
// one git remote). Local-only projects have a unique `local:<slug>` id and
|
|
197
|
+
// never merge. The most-recently-active member is canonical (its slug + title
|
|
198
|
+
// win, so rename / room links keep working).
|
|
199
|
+
const byId = new Map();
|
|
200
|
+
for (const s of perSlug) {
|
|
201
|
+
const g = byId.get(s.id);
|
|
202
|
+
if (g)
|
|
203
|
+
g.push(s);
|
|
204
|
+
else
|
|
205
|
+
byId.set(s.id, [s]);
|
|
206
|
+
}
|
|
207
|
+
const summaries = [];
|
|
208
|
+
for (const [id, members] of byId) {
|
|
209
|
+
members.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
210
|
+
const head = members[0];
|
|
211
|
+
const localPaths = [...new Set(members.map((m) => m.cwd).filter((c) => !!c))];
|
|
212
|
+
// Recency-ordered agent stack across all members (head first).
|
|
213
|
+
const recentAgents = [];
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
for (const m of members) {
|
|
216
|
+
for (const a of m.agentChrono) {
|
|
65
217
|
if (seen.has(a))
|
|
66
218
|
continue;
|
|
67
219
|
seen.add(a);
|
|
@@ -69,16 +221,22 @@ export function listProjects(options) {
|
|
|
69
221
|
if (recentAgents.length >= 3)
|
|
70
222
|
break;
|
|
71
223
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
cwd: thread.cwd,
|
|
75
|
-
title: thread.title,
|
|
76
|
-
taskCount: thread.taskCount,
|
|
77
|
-
lastActivityAt: thread.lastActivityAt,
|
|
78
|
-
lastAgent,
|
|
79
|
-
recentAgents,
|
|
80
|
-
});
|
|
224
|
+
if (recentAgents.length >= 3)
|
|
225
|
+
break;
|
|
81
226
|
}
|
|
227
|
+
summaries.push({
|
|
228
|
+
id,
|
|
229
|
+
remote: head.remote,
|
|
230
|
+
slug: head.slug,
|
|
231
|
+
cwd: head.cwd,
|
|
232
|
+
localPaths,
|
|
233
|
+
title: head.title,
|
|
234
|
+
taskCount: members.reduce((n, m) => n + m.taskCount, 0),
|
|
235
|
+
totalCostCents: Math.round(members.reduce((n, m) => n + m.totalCostCents, 0) * 100) / 100,
|
|
236
|
+
lastActivityAt: members.reduce((acc, m) => (m.lastActivityAt && (acc === null || m.lastActivityAt > acc) ? m.lastActivityAt : acc), null),
|
|
237
|
+
lastAgent: recentAgents[0] ?? null,
|
|
238
|
+
recentAgents,
|
|
239
|
+
});
|
|
82
240
|
}
|
|
83
241
|
// Sort by recency (most recent first)
|
|
84
242
|
summaries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
@@ -99,7 +257,9 @@ export function readProjectThread(slug, options) {
|
|
|
99
257
|
}
|
|
100
258
|
let cwd = null;
|
|
101
259
|
let title = null;
|
|
260
|
+
let remote = null;
|
|
102
261
|
const tasksById = new Map();
|
|
262
|
+
const archivedState = new Map(); // sessionId -> archived (latest event wins)
|
|
103
263
|
for (const line of content.split('\n')) {
|
|
104
264
|
const trimmed = line.trim();
|
|
105
265
|
if (!trimmed)
|
|
@@ -116,12 +276,21 @@ export function readProjectThread(slug, options) {
|
|
|
116
276
|
cwd = rec.cwd;
|
|
117
277
|
if (typeof rec.title === 'string')
|
|
118
278
|
title = rec.title;
|
|
279
|
+
if (typeof rec.remote === 'string')
|
|
280
|
+
remote = rec.remote;
|
|
119
281
|
}
|
|
120
282
|
else if (rec._meta === 'project-update') {
|
|
121
283
|
if (typeof rec.title === 'string')
|
|
122
284
|
title = rec.title;
|
|
123
285
|
if (typeof rec.cwd === 'string' && cwd === null)
|
|
124
286
|
cwd = rec.cwd;
|
|
287
|
+
// Latest non-empty remote wins; an explicit null (checked, none found)
|
|
288
|
+
// leaves the prior value alone — we only ever upgrade toward a real remote.
|
|
289
|
+
if (typeof rec.remote === 'string' && rec.remote)
|
|
290
|
+
remote = rec.remote;
|
|
291
|
+
}
|
|
292
|
+
else if (rec._meta === 'archived' && typeof rec.sessionId === 'string') {
|
|
293
|
+
archivedState.set(rec.sessionId, rec.archived === true);
|
|
125
294
|
}
|
|
126
295
|
else if (rec.type === 'task' && typeof rec.sessionId === 'string') {
|
|
127
296
|
const existing = tasksById.get(rec.sessionId) ?? {
|
|
@@ -143,6 +312,8 @@ export function readProjectThread(slug, options) {
|
|
|
143
312
|
'completedAt',
|
|
144
313
|
'steps',
|
|
145
314
|
'artifacts',
|
|
315
|
+
'parentSessionId',
|
|
316
|
+
'finalReport',
|
|
146
317
|
]) {
|
|
147
318
|
if (rec[k] !== undefined) {
|
|
148
319
|
existing[k] = rec[k];
|
|
@@ -151,6 +322,12 @@ export function readProjectThread(slug, options) {
|
|
|
151
322
|
tasksById.set(rec.sessionId, existing);
|
|
152
323
|
}
|
|
153
324
|
}
|
|
325
|
+
// Stamp the archived flag (soft-delete) from the latest archived event per task.
|
|
326
|
+
for (const t of tasksById.values()) {
|
|
327
|
+
const a = archivedState.get(t.sessionId);
|
|
328
|
+
if (a !== undefined)
|
|
329
|
+
t.archived = a;
|
|
330
|
+
}
|
|
154
331
|
const tasks = [...tasksById.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
155
332
|
const lastActivityAt = tasks.reduce((acc, t) => {
|
|
156
333
|
const ts = t.completedAt ?? t.startedAt;
|
|
@@ -164,9 +341,69 @@ export function readProjectThread(slug, options) {
|
|
|
164
341
|
slug,
|
|
165
342
|
cwd,
|
|
166
343
|
title,
|
|
167
|
-
|
|
344
|
+
remote,
|
|
345
|
+
// Count only what the UI actually shows: the sidebar tree + room list both
|
|
346
|
+
// hide archived + demo/seed tasks, so counting them inflated the badge
|
|
347
|
+
// (a project read "420" while showing a handful).
|
|
348
|
+
taskCount: countVisibleTasks(tasks),
|
|
168
349
|
tasks: cappedTasks,
|
|
169
350
|
lastActivityAt,
|
|
170
351
|
};
|
|
171
352
|
}
|
|
353
|
+
/** The honest THREAD count: distinct top-level conversations a user started.
|
|
354
|
+
* Excludes soft-deleted (archived) and demo/seed tasks, and — the big one —
|
|
355
|
+
* tasks with a parentSessionId, which are reply turns + auto cross-vendor
|
|
356
|
+
* reviewers (continuations of a thread, not new threads). Counting those made a
|
|
357
|
+
* project read "400+" while showing a handful of real threads. */
|
|
358
|
+
function countVisibleTasks(tasks) {
|
|
359
|
+
return tasks.filter((t) => {
|
|
360
|
+
if (t.archived || t.parentSessionId)
|
|
361
|
+
return false;
|
|
362
|
+
if (/^demo-/.test(t.sessionId || ''))
|
|
363
|
+
return false;
|
|
364
|
+
// Failover-lineage marker ("↻ Switched from X to Y (hit a rate limit)") — a
|
|
365
|
+
// continuation edge, not a thread the user started. Matches the sidebar tree.
|
|
366
|
+
const p = t.prompt || '';
|
|
367
|
+
if (p.includes('Switched from') && p.includes('rate limit'))
|
|
368
|
+
return false;
|
|
369
|
+
return true;
|
|
370
|
+
}).length;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Like readProjectThread, but merges every clone of the same repo (slugs that share
|
|
374
|
+
* a git remote) into ONE thread — so a project room shows the repo's FULL history,
|
|
375
|
+
* not just the single slug the UI happened to open. A local-only project (no remote)
|
|
376
|
+
* has no clones to merge and is returned unchanged. Tasks are deduped by sessionId.
|
|
377
|
+
*/
|
|
378
|
+
export function readMergedProjectThread(slug, options) {
|
|
379
|
+
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
380
|
+
const head = readProjectThread(slug, { projectsDir: root });
|
|
381
|
+
if (!head || !head.remote)
|
|
382
|
+
return head;
|
|
383
|
+
const id = normalizeRemote(head.remote);
|
|
384
|
+
let entries;
|
|
385
|
+
try {
|
|
386
|
+
entries = readdirSync(root).filter((e) => /^[a-f0-9]{12}$/.test(e) && e !== slug);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return head;
|
|
390
|
+
}
|
|
391
|
+
const byId = new Map();
|
|
392
|
+
for (const t of head.tasks)
|
|
393
|
+
byId.set(t.sessionId, t);
|
|
394
|
+
for (const e of entries) {
|
|
395
|
+
const sib = readProjectThread(e, { projectsDir: root });
|
|
396
|
+
if (!sib || normalizeRemote(sib.remote) !== id)
|
|
397
|
+
continue;
|
|
398
|
+
for (const t of sib.tasks)
|
|
399
|
+
if (!byId.has(t.sessionId))
|
|
400
|
+
byId.set(t.sessionId, t);
|
|
401
|
+
}
|
|
402
|
+
const tasks = [...byId.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
403
|
+
const lastActivityAt = tasks.reduce((acc, t) => {
|
|
404
|
+
const ts = t.completedAt ?? t.startedAt;
|
|
405
|
+
return ts && (acc === null || ts > acc) ? ts : acc;
|
|
406
|
+
}, null);
|
|
407
|
+
return { ...head, tasks, taskCount: countVisibleTasks(tasks), lastActivityAt };
|
|
408
|
+
}
|
|
172
409
|
//# sourceMappingURL=project-thread-reader.js.map
|
package/dist/monitoring.js
CHANGED
|
@@ -201,10 +201,10 @@ export function getMetrics() {
|
|
|
201
201
|
* Format health status for display
|
|
202
202
|
*/
|
|
203
203
|
export function formatHealthStatus(health) {
|
|
204
|
-
const statusIcon = health.status === 'healthy' ? '✓' : health.status === 'degraded' ? '
|
|
204
|
+
const statusIcon = health.status === 'healthy' ? '✓' : health.status === 'degraded' ? '!' : '✕';
|
|
205
205
|
let output = ` ${statusIcon} ${health.status.toUpperCase()}\n\n`;
|
|
206
206
|
for (const [component, check] of Object.entries(health.checks)) {
|
|
207
|
-
const icon = check.status === 'ok' ? '✓' : check.status === 'warning' ? '
|
|
207
|
+
const icon = check.status === 'ok' ? '✓' : check.status === 'warning' ? '!' : '✕';
|
|
208
208
|
output += ` ${icon} ${component.padEnd(12)} ${check.message}\n`;
|
|
209
209
|
}
|
|
210
210
|
return output;
|