codiedev 0.10.0-beta.1 → 0.10.0-beta.10
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 +11 -5
- package/assets/opencode-plugin.mjs +6 -0
- package/assets/session-key.mjs +48 -0
- package/assets/tui-brand-plugin.tsx +73 -0
- package/dist/backup/cache.js +1 -5
- package/dist/backup/match.js +0 -6
- package/dist/backup/run.js +3 -35
- package/dist/captureExclusions.js +0 -12
- package/dist/captureLog.js +0 -28
- package/dist/captureReadiness.js +0 -58
- package/dist/captureSignals.js +184 -0
- package/dist/cli.js +135 -72
- package/dist/cliSession.js +456 -0
- package/dist/codexHookTrust.js +125 -6
- package/dist/codexLimitScan.js +359 -0
- package/dist/codie.js +77 -130
- package/dist/codieBrandInstall.js +80 -0
- package/dist/codieLauncher.js +76 -79
- package/dist/codieRuntime.js +19 -23
- package/dist/codieRuntimeInstall.js +108 -23
- package/dist/codieUsage.js +181 -0
- package/dist/commands/agentTemplates.generated.js +136 -0
- package/dist/commands/agents.js +1088 -0
- package/dist/commands/agentsSdk.generated.js +7 -0
- package/dist/commands/ask.js +0 -3
- package/dist/commands/capture.js +0 -2
- package/dist/commands/createTicket.js +0 -10
- package/dist/commands/doctor.js +53 -120
- package/dist/commands/evals.js +187 -0
- package/dist/commands/inbox.js +0 -2
- package/dist/commands/library.js +0 -3
- package/dist/commands/pack.js +573 -0
- package/dist/commands/ping.js +0 -2
- package/dist/commands/pull.js +6 -10
- package/dist/commands/push.js +2 -12
- package/dist/commands/search.js +0 -4
- package/dist/commands/send.js +0 -5
- package/dist/commands/share.js +0 -4
- package/dist/commands/shared.js +0 -35
- package/dist/commands/traces.js +344 -0
- package/dist/commands/tracesLabel.js +348 -0
- package/dist/configDir.js +18 -11
- package/dist/connect.js +55 -118
- package/dist/connectFlow.js +35 -9
- package/dist/copilotHooks.js +391 -0
- package/dist/copilotInstallation.js +64 -0
- package/dist/cursorHooks.js +38 -30
- package/dist/cursorLifecycle.js +109 -0
- package/dist/detection.js +0 -30
- package/dist/harnessDirs.js +77 -0
- package/dist/hook.js +256 -445
- package/dist/inboxFormat.js +0 -24
- package/dist/launchContext.js +150 -0
- package/dist/launcher.js +168 -0
- package/dist/limitEvent.js +44 -13
- package/dist/limitEventReporter.js +1 -1
- package/dist/managedPi.js +256 -0
- package/dist/mcp.js +5 -384
- package/dist/nativeSteering.js +24 -0
- package/dist/packs/codie.js +182 -0
- package/dist/packs/harnesses.js +411 -0
- package/dist/packs/hookGate.js +256 -0
- package/dist/packs/install.js +286 -0
- package/dist/packs/manifest.js +126 -0
- package/dist/packs/paths.js +81 -0
- package/dist/packs/shim.js +214 -0
- package/dist/packs/store.js +155 -0
- package/dist/packs/sync.js +170 -0
- package/dist/piHooks.js +216 -0
- package/dist/piRuntime.js +120 -0
- package/dist/repoResolver.js +0 -19
- package/dist/secret.js +0 -3
- package/dist/selfUpdate.js +376 -0
- package/dist/skillMarker.js +23 -0
- package/dist/subagents.js +135 -0
- package/dist/teamSkillContext.js +10 -0
- package/dist/transcriptModels.js +205 -0
- package/dist/upgradeNudge.js +0 -30
- package/dist/utils.js +502 -828
- package/dist/version.js +0 -10
- package/dist/versionCheck.js +0 -33
- package/package.json +9 -10
- package/dist/backup/cache.d.ts +0 -14
- package/dist/backup/match.d.ts +0 -2
- package/dist/backup/run.d.ts +0 -25
- package/dist/captureExclusions.d.ts +0 -30
- package/dist/captureLog.d.ts +0 -20
- package/dist/captureReadiness.d.ts +0 -65
- package/dist/cli.d.ts +0 -16
- package/dist/codexHookTrust.d.ts +0 -34
- package/dist/codie.d.ts +0 -2
- package/dist/codieLauncher.d.ts +0 -53
- package/dist/codieRuntime.d.ts +0 -29
- package/dist/codieRuntimeInstall.d.ts +0 -28
- package/dist/codieStartup.d.ts +0 -26
- package/dist/codieStartup.js +0 -30
- package/dist/commands/ask.d.ts +0 -1
- package/dist/commands/capture.d.ts +0 -14
- package/dist/commands/createTicket.d.ts +0 -27
- package/dist/commands/delete.d.ts +0 -1
- package/dist/commands/doctor.d.ts +0 -1
- package/dist/commands/inbox.d.ts +0 -3
- package/dist/commands/library.d.ts +0 -2
- package/dist/commands/note.d.ts +0 -1
- package/dist/commands/ping.d.ts +0 -1
- package/dist/commands/promote.d.ts +0 -1
- package/dist/commands/promote.js +0 -53
- package/dist/commands/pull.d.ts +0 -2
- package/dist/commands/push.d.ts +0 -33
- package/dist/commands/react.d.ts +0 -2
- package/dist/commands/react.js +0 -40
- package/dist/commands/reverseTicket.d.ts +0 -2
- package/dist/commands/reverseTicket.js +0 -477
- package/dist/commands/search.d.ts +0 -2
- package/dist/commands/security.d.ts +0 -1
- package/dist/commands/security.js +0 -156
- package/dist/commands/send.d.ts +0 -2
- package/dist/commands/share.d.ts +0 -2
- package/dist/commands/shared.d.ts +0 -64
- package/dist/configDir.d.ts +0 -1
- package/dist/connect.d.ts +0 -1
- package/dist/connectFlow.d.ts +0 -13
- package/dist/cursorHooks.d.ts +0 -23
- package/dist/detection.d.ts +0 -30
- package/dist/hook.d.ts +0 -6
- package/dist/inboxFormat.d.ts +0 -38
- package/dist/limitEvent.d.ts +0 -16
- package/dist/limitEventReporter.d.ts +0 -21
- package/dist/mcp.d.ts +0 -12
- package/dist/prUrl.d.ts +0 -9
- package/dist/prUrl.js +0 -33
- package/dist/repoResolver.d.ts +0 -17
- package/dist/secret.d.ts +0 -1
- package/dist/security.d.ts +0 -102
- package/dist/security.js +0 -437
- package/dist/securityDeepScan.d.ts +0 -41
- package/dist/securityDeepScan.js +0 -248
- package/dist/securityDoctor.d.ts +0 -32
- package/dist/securityDoctor.js +0 -63
- package/dist/securityFeature.d.ts +0 -5
- package/dist/securityFeature.js +0 -7
- package/dist/securityInstall.d.ts +0 -63
- package/dist/securityInstall.js +0 -239
- package/dist/securityInventory.d.ts +0 -54
- package/dist/securityInventory.js +0 -556
- package/dist/securityPilot.d.ts +0 -19
- package/dist/securityPilot.js +0 -61
- package/dist/securityPolicy.d.ts +0 -33
- package/dist/securityPolicy.js +0 -188
- package/dist/securitySession.d.ts +0 -6
- package/dist/securitySession.js +0 -72
- package/dist/upgradeNudge.d.ts +0 -25
- package/dist/utils.d.ts +0 -342
- package/dist/version.d.ts +0 -1
- package/dist/versionCheck.d.ts +0 -68
package/README.md
CHANGED
|
@@ -5,14 +5,20 @@ Connect Claude Code, Codex, Cursor, or OpenCode to Codiedev for team session cap
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
npm install -g codiedev@latest
|
|
9
|
+
codiedev connect
|
|
10
|
+
codiedev doctor
|
|
9
11
|
```
|
|
10
12
|
|
|
13
|
+
If global npm installs are blocked, use `npx codiedev@latest connect` and
|
|
14
|
+
`npx codiedev@latest doctor` instead. The agent instructions include the same
|
|
15
|
+
fallback for other commands.
|
|
16
|
+
|
|
11
17
|
The CLI will prompt you for an API token (get one at https://codiedev.com/portal/integrations/claude-code), then install a capture hook into whichever agent CLIs are present on your machine:
|
|
12
18
|
|
|
13
19
|
- **Claude Code** — `SessionEnd` hook in `~/.claude/settings.json`
|
|
14
20
|
- **Codex** — `Stop` hook in `~/.codex/hooks.json`
|
|
15
|
-
- **Cursor** — native session,
|
|
21
|
+
- **Cursor** — native session, subagent/tool observation, backup, and capture hooks in `~/.cursor/hooks.json`
|
|
16
22
|
- **OpenCode / Codie** — plugin in `~/.config/opencode/plugins/codiedev.js`
|
|
17
23
|
|
|
18
24
|
Installed clients are wired automatically. The OpenCode adapter is staged even
|
|
@@ -51,9 +57,9 @@ Codex doesn't currently expose a `SessionEnd` event, so the hook runs on `Stop`
|
|
|
51
57
|
|
|
52
58
|
## Cursor note
|
|
53
59
|
|
|
54
|
-
Cursor uses its native hook events for session start/end,
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
Cursor uses its native hook events for session start/end, tool and subagent
|
|
61
|
+
observation, and stop-time backup. Cursor's `beforeSubmitPrompt` cannot inject
|
|
62
|
+
dynamic context today, so task-specific discovery is not claimed.
|
|
57
63
|
Cursor sessions keep their source
|
|
58
64
|
label and real transcript statistics through the shared capture pipeline.
|
|
59
65
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
trackWorkPath,
|
|
11
11
|
workDirBySession,
|
|
12
12
|
} from "./git-refs.mjs";
|
|
13
|
+
import { freshGatewayKey } from "./session-key.mjs";
|
|
13
14
|
|
|
14
15
|
const skillUseBySession = new Map();
|
|
15
16
|
|
|
@@ -60,6 +61,11 @@ export const CodiedevMetadata = async () => ({
|
|
|
60
61
|
});
|
|
61
62
|
},
|
|
62
63
|
"chat.headers": async (input, output) => {
|
|
64
|
+
// Browser sign-in: the current 24h token, refreshed as it nears expiry,
|
|
65
|
+
// so a session open for days keeps working (see session-key.mjs). Exact
|
|
66
|
+
// "Authorization" key: per-call headers win over the apiKey-derived one.
|
|
67
|
+
const key = await freshGatewayKey();
|
|
68
|
+
if (key) output.headers.Authorization = `Bearer ${key}`;
|
|
63
69
|
Object.assign(
|
|
64
70
|
output.headers,
|
|
65
71
|
buildAttributionHeaders({
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The gateway key for one model request, kept fresh for the whole session.
|
|
2
|
+
//
|
|
3
|
+
// With browser sign-in (`codiedev connect`), the key is a 24h access token.
|
|
4
|
+
// The engine (OpenCode, pi) would otherwise hold the one it was launched
|
|
5
|
+
// with until the session ends, so a session open for more than a day would
|
|
6
|
+
// start failing. `codie` sets these variables only for a browser sign-in:
|
|
7
|
+
// CODIEDEV_SESSION_CONFIG path to ~/.codiedev/config.json
|
|
8
|
+
// CODIEDEV_CLI_JS the CLI entry (dist/cli.js)
|
|
9
|
+
// CODIEDEV_NODE the node binary that runs it
|
|
10
|
+
// Each request reads the current token from config (other codiedev
|
|
11
|
+
// processes may have refreshed it). When it has under 30 minutes left, this
|
|
12
|
+
// runs `codiedev session-refresh`, which refreshes under the shared lock
|
|
13
|
+
// (cliSession.ts, specs/tla/CliSession.tla), then reads again.
|
|
14
|
+
// No variables (pasted API token, CI): returns null and the launch key stands.
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
export const REFRESH_WHEN_LEFT_MS = 30 * 60_000;
|
|
20
|
+
|
|
21
|
+
function readSession(path) {
|
|
22
|
+
try {
|
|
23
|
+
const c = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
return c && c.auth && typeof c.token === "string" && c.token ? c : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runRefresh(env) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
execFile(env.CODIEDEV_NODE, [env.CODIEDEV_CLI_JS, "session-refresh"], { timeout: 30_000, env }, () => resolve());
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fresh gateway key, or null when the session isn't a browser sign-in. */
|
|
37
|
+
export async function freshGatewayKey(env = process.env, now = Date.now, refresh = runRefresh) {
|
|
38
|
+
const path = env.CODIEDEV_SESSION_CONFIG;
|
|
39
|
+
if (!path || !env.CODIEDEV_CLI_JS || !env.CODIEDEV_NODE) return null;
|
|
40
|
+
let session = readSession(path);
|
|
41
|
+
if (!session) return null;
|
|
42
|
+
const left = Number(session.auth.accessTokenExpiresAt) - now();
|
|
43
|
+
if (!(left > REFRESH_WHEN_LEFT_MS)) {
|
|
44
|
+
await refresh(env);
|
|
45
|
+
session = readSession(path) ?? session;
|
|
46
|
+
}
|
|
47
|
+
return session.token;
|
|
48
|
+
}
|
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
// over the product name, with a "powered by opencode" credit line beneath.
|
|
5
5
|
// Installed and kept current by `codie`; deleting this file and its tui.json
|
|
6
6
|
// entry restores the stock logo.
|
|
7
|
+
// Also paints the governed status line beside the prompt: who is spending,
|
|
8
|
+
// how much of which budget, and what is in flight. The numbers and the
|
|
9
|
+
// wording come from the codiedev package's tested usage module, whose
|
|
10
|
+
// absolute path `codie` bakes in when it stages this file.
|
|
7
11
|
// Mark ported from the retired Grok-fork TUI (assets/logo/logo07.txt), itself
|
|
8
12
|
// derived from vm-demo/public/codiedev-icon.png — keep aligned to that
|
|
9
13
|
// intertwined c/d silhouette when refreshing brand assets.
|
|
10
14
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
11
15
|
import { TextAttributes } from "@opentui/core"
|
|
16
|
+
import { createSignal } from "solid-js"
|
|
12
17
|
|
|
13
18
|
const MARK = [
|
|
14
19
|
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿",
|
|
@@ -20,6 +25,68 @@ const MARK = [
|
|
|
20
25
|
"⠀⠈⠻⢿⣿⣿⣿⠿⠋⠀⠀⠙⠿⣿⣿⣿⡿⠟⠁",
|
|
21
26
|
]
|
|
22
27
|
|
|
28
|
+
// One poller per TUI process, shared by every slot that shows the line. A
|
|
29
|
+
// failure anywhere here costs the status line and nothing else: the home
|
|
30
|
+
// screen still brands, and the developer still codes.
|
|
31
|
+
const REFRESH_MS = 30_000
|
|
32
|
+
|
|
33
|
+
const [usageLine, setUsageLine] = createSignal("")
|
|
34
|
+
const [usageTone, setUsageTone] = createSignal("normal")
|
|
35
|
+
|
|
36
|
+
async function startUsagePolling() {
|
|
37
|
+
let usage: any
|
|
38
|
+
try {
|
|
39
|
+
// Imported here, not at the top of the file. This plugin is copied
|
|
40
|
+
// outside the package, so its resolution is the engine loader's business:
|
|
41
|
+
// a top-level import that fails takes the whole module with it, and the
|
|
42
|
+
// developer silently loses the brand mark that worked before. solid-js
|
|
43
|
+
// stays a static import because @opentui/solid is already this file's JSX
|
|
44
|
+
// runtime — if Solid were missing the logo would be gone regardless.
|
|
45
|
+
const { createRequire } = await import("module")
|
|
46
|
+
usage = createRequire(import.meta.url)(__CODIEDEV_USAGE_MODULE__)
|
|
47
|
+
} catch {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const key = process.env.CODIEDEV_GATEWAY_KEY
|
|
51
|
+
if (!key || typeof usage?.fetchCodieUsage !== "function") return
|
|
52
|
+
const poll = async () => {
|
|
53
|
+
try {
|
|
54
|
+
const snapshot = await usage.fetchCodieUsage({
|
|
55
|
+
baseUrl: process.env.CODIEDEV_GATEWAY_URL,
|
|
56
|
+
key,
|
|
57
|
+
})
|
|
58
|
+
// A failed poll leaves the last good line up rather than blanking it:
|
|
59
|
+
// a number that stopped updating beats a number that vanished.
|
|
60
|
+
if (snapshot) {
|
|
61
|
+
setUsageLine(usage.formatUsageLine(snapshot))
|
|
62
|
+
// Older installs of the usage module have no tone; stay muted there.
|
|
63
|
+
if (typeof usage.usageTone === "function") setUsageTone(usage.usageTone(snapshot))
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
void poll()
|
|
68
|
+
const timer = setInterval(poll, REFRESH_MS)
|
|
69
|
+
// Never hold the process open for a status poll.
|
|
70
|
+
;(timer as any).unref?.()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
void startUsagePolling().catch(() => {})
|
|
74
|
+
|
|
75
|
+
function StatusLine(props: { theme: any }) {
|
|
76
|
+
return (
|
|
77
|
+
<text
|
|
78
|
+
fg={usageTone() === "critical"
|
|
79
|
+
? (props.theme.error ?? props.theme.textMuted)
|
|
80
|
+
: usageTone() === "warn"
|
|
81
|
+
? (props.theme.warning ?? props.theme.textMuted)
|
|
82
|
+
: props.theme.textMuted}
|
|
83
|
+
selectable={false}
|
|
84
|
+
>
|
|
85
|
+
{usageLine()}
|
|
86
|
+
</text>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
23
90
|
const tui: TuiPlugin = async (api) => {
|
|
24
91
|
api.slots.register({
|
|
25
92
|
slots: {
|
|
@@ -51,6 +118,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
51
118
|
</box>
|
|
52
119
|
)
|
|
53
120
|
},
|
|
121
|
+
home_prompt_right(ctx) {
|
|
122
|
+
return <StatusLine theme={ctx.theme.current} />
|
|
123
|
+
},
|
|
124
|
+
session_prompt_right(ctx) {
|
|
125
|
+
return <StatusLine theme={ctx.theme.current} />
|
|
126
|
+
},
|
|
54
127
|
},
|
|
55
128
|
})
|
|
56
129
|
}
|
package/dist/backup/cache.js
CHANGED
|
@@ -49,7 +49,7 @@ function loadCache(readFileSync = fs.readFileSync) {
|
|
|
49
49
|
return JSON.parse(readFileSync(CACHE_PATH, "utf8"));
|
|
50
50
|
}
|
|
51
51
|
catch {
|
|
52
|
-
return {};
|
|
52
|
+
return {};
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
function saveCache(cache, deps = fs) {
|
|
@@ -58,12 +58,8 @@ function saveCache(cache, deps = fs) {
|
|
|
58
58
|
deps.writeFileSync(CACHE_PATH, JSON.stringify(cache), { mode: 0o600 });
|
|
59
59
|
}
|
|
60
60
|
catch {
|
|
61
|
-
// best-effort — a failed cache write just means a redundant push next turn,
|
|
62
|
-
// which the server idempotently no-ops.
|
|
63
61
|
}
|
|
64
62
|
}
|
|
65
|
-
// Pure: given the prior cache, a repo key, and this turn's candidates, return
|
|
66
|
-
// the subset whose hash changed plus the cache to persist.
|
|
67
63
|
function applyCache(prev, repoKey, candidates) {
|
|
68
64
|
const next = { ...prev };
|
|
69
65
|
const changed = [];
|
package/dist/backup/match.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Pure path matcher for the auto-backup hook. Decides whether a repo-relative
|
|
3
|
-
// path is an "artifact" worth backing up. Deny rules win over allow rules.
|
|
4
|
-
// `getContent` is lazy: only called when a path-level allow needs the
|
|
5
|
-
// skill-frontmatter exclusion (skills are pushed deliberately, not auto-backed).
|
|
6
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
3
|
exports.hasSkillFrontmatter = hasSkillFrontmatter;
|
|
8
4
|
exports.isArtifactPath = isArtifactPath;
|
|
@@ -15,7 +11,6 @@ function basename(p) {
|
|
|
15
11
|
const i = p.lastIndexOf("/");
|
|
16
12
|
return i === -1 ? p : p.slice(i + 1);
|
|
17
13
|
}
|
|
18
|
-
// A leading `---` block containing both `name:` and `description:` → skill.
|
|
19
14
|
function hasSkillFrontmatter(md) {
|
|
20
15
|
const m = md.match(/^?---\s*\n([\s\S]*?)\n---/);
|
|
21
16
|
if (!m)
|
|
@@ -62,7 +57,6 @@ function isArtifactPath(rel, getContent) {
|
|
|
62
57
|
return false;
|
|
63
58
|
}
|
|
64
59
|
catch {
|
|
65
|
-
// unreadable → treat as non-skill; the push will fail harmlessly later
|
|
66
60
|
}
|
|
67
61
|
return true;
|
|
68
62
|
}
|
package/dist/backup/run.js
CHANGED
|
@@ -40,7 +40,6 @@ exports.runBackup = runBackup;
|
|
|
40
40
|
exports.runBackupUpload = runBackupUpload;
|
|
41
41
|
const match_1 = require("./match");
|
|
42
42
|
const cache_1 = require("./cache");
|
|
43
|
-
// Pure: merge two path lists into one, deduped, preserving first-seen order.
|
|
44
43
|
function unionPaths(a, b) {
|
|
45
44
|
const seen = new Set();
|
|
46
45
|
const out = [];
|
|
@@ -56,8 +55,6 @@ function unionPaths(a, b) {
|
|
|
56
55
|
}
|
|
57
56
|
return out;
|
|
58
57
|
}
|
|
59
|
-
// Pure: from this turn's git-status paths, keep artifact files whose content
|
|
60
|
-
// hash changed since the cache. Returns the changed candidates + next cache.
|
|
61
58
|
function selectBackupFiles(input) {
|
|
62
59
|
const candidates = [];
|
|
63
60
|
for (const rel of input.statusPaths) {
|
|
@@ -70,7 +67,7 @@ function selectBackupFiles(input) {
|
|
|
70
67
|
c = getContent();
|
|
71
68
|
}
|
|
72
69
|
catch {
|
|
73
|
-
continue;
|
|
70
|
+
continue;
|
|
74
71
|
}
|
|
75
72
|
if (c === undefined || c === null)
|
|
76
73
|
continue;
|
|
@@ -99,11 +96,7 @@ function gitRoot(cwd) {
|
|
|
99
96
|
}
|
|
100
97
|
function gitStatusPaths(root) {
|
|
101
98
|
try {
|
|
102
|
-
// --untracked-files=all is REQUIRED: the default collapses untracked files
|
|
103
|
-
// into their top-level directory (a new docs/superpowers/plans/x.md shows as
|
|
104
|
-
// just "docs/"), which would make the hook miss brand-new artifact files.
|
|
105
99
|
const out = (0, child_process_1.execFileSync)("git", ["-C", root, "status", "--porcelain", "-z", "--untracked-files=all"], { encoding: "utf8" });
|
|
106
|
-
// -z: NUL-separated, each entry "XY <path>"; rename entries have two paths.
|
|
107
100
|
return out
|
|
108
101
|
.split("\0")
|
|
109
102
|
.filter(Boolean)
|
|
@@ -114,8 +107,6 @@ function gitStatusPaths(root) {
|
|
|
114
107
|
return [];
|
|
115
108
|
}
|
|
116
109
|
}
|
|
117
|
-
// All git-tracked files (committed, regardless of dirty state). `git ls-files`
|
|
118
|
-
// emits bare NUL-separated paths — no "XY " status prefix — so we don't slice.
|
|
119
110
|
function gitTrackedPaths(root) {
|
|
120
111
|
try {
|
|
121
112
|
const out = (0, child_process_1.execFileSync)("git", ["-C", root, "ls-files", "-z"], {
|
|
@@ -127,16 +118,10 @@ function gitTrackedPaths(root) {
|
|
|
127
118
|
return [];
|
|
128
119
|
}
|
|
129
120
|
}
|
|
130
|
-
// The hook's candidate set: dirty/untracked files ∪ all tracked files. Feeding
|
|
131
|
-
// tracked files in is what lets a COMMITTED artifact (clean status) still get
|
|
132
|
-
// backed up — the hash cache in selectBackupFiles dedups so already-backed-up
|
|
133
|
-
// files don't re-upload. selectBackupFiles rejects non-artifact paths cheaply
|
|
134
|
-
// (by regex, no content read), so the full tracked list is fine to pass.
|
|
135
121
|
function gitCandidatePaths(root) {
|
|
136
122
|
return unionPaths(gitStatusPaths(root), gitTrackedPaths(root));
|
|
137
123
|
}
|
|
138
124
|
const sha256 = (s) => crypto.createHash("sha256").update(s, "utf8").digest("hex");
|
|
139
|
-
// Stop/sessionEnd hook entry. Best-effort, never throws.
|
|
140
125
|
function runBackup(cwd, hookScriptPath) {
|
|
141
126
|
try {
|
|
142
127
|
if (process.env.CODIEDEV_NO_AUTOBACKUP)
|
|
@@ -150,8 +135,6 @@ function runBackup(cwd, hookScriptPath) {
|
|
|
150
135
|
if (!root)
|
|
151
136
|
return;
|
|
152
137
|
const repoKey = (0, utils_1.getGitRemoteUrl)(root) ?? root;
|
|
153
|
-
// Dirty files ∪ tracked files — so committed-but-unbacked artifacts are
|
|
154
|
-
// caught too, not just unsaved drafts. Hash cache dedups the rest.
|
|
155
138
|
const statusPaths = gitCandidatePaths(root);
|
|
156
139
|
if (statusPaths.length === 0)
|
|
157
140
|
return;
|
|
@@ -164,12 +147,6 @@ function runBackup(cwd, hookScriptPath) {
|
|
|
164
147
|
});
|
|
165
148
|
if (changed.length === 0)
|
|
166
149
|
return;
|
|
167
|
-
// The detached child owns the cache: it writes each file's hash ONLY after a
|
|
168
|
-
// successful push, so a genuine push failure (network/500) leaves the hash
|
|
169
|
-
// uncached and the file retries next turn. (Persisting here optimistically
|
|
170
|
-
// would suppress that retry, since the server no-op only covers successes.)
|
|
171
|
-
// Job carries root + repoKey + files; the child re-reads config from disk
|
|
172
|
-
// via readConfig() (same machine), avoiding any config-shape guessing.
|
|
173
150
|
const job = {
|
|
174
151
|
root,
|
|
175
152
|
repoKey,
|
|
@@ -184,18 +161,13 @@ function runBackup(cwd, hookScriptPath) {
|
|
|
184
161
|
child.unref();
|
|
185
162
|
}
|
|
186
163
|
catch {
|
|
187
|
-
// best-effort
|
|
188
164
|
}
|
|
189
165
|
}
|
|
190
166
|
const DEFAULT_BACKUP_UPLOAD_DEPS = { readConfig: utils_1.readConfig, loadCache: cache_2.loadCache, saveCache: cache_2.saveCache };
|
|
191
|
-
// Detached child: push each queued file privately, caching its hash ONLY on a
|
|
192
|
-
// successful push (so a failed push retries next turn), then delete the job
|
|
193
|
-
// file. Deps are injectable so the cache write is testable without touching the
|
|
194
|
-
// global ~/.codiedev cache.
|
|
195
167
|
async function runBackupUpload(jobFile, deps = DEFAULT_BACKUP_UPLOAD_DEPS) {
|
|
196
168
|
try {
|
|
197
169
|
const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
198
|
-
const config = deps.readConfig();
|
|
170
|
+
const config = deps.readConfig();
|
|
199
171
|
if (!config)
|
|
200
172
|
return;
|
|
201
173
|
if ((0, captureExclusions_1.isCaptureIgnored)(job.root, config.captureIgnoredPaths))
|
|
@@ -210,25 +182,21 @@ async function runBackupUpload(jobFile, deps = DEFAULT_BACKUP_UPLOAD_DEPS) {
|
|
|
210
182
|
visibility: "private",
|
|
211
183
|
contentHash: f.hash,
|
|
212
184
|
});
|
|
213
|
-
// Only now that the push landed do we record the hash — a prior failure
|
|
214
|
-
// leaves it uncached so the next turn retries it.
|
|
215
185
|
cache[(0, cache_2.cacheKey)(job.repoKey, f.rel)] = f.hash;
|
|
216
186
|
dirty = true;
|
|
217
187
|
}
|
|
218
188
|
catch {
|
|
219
|
-
// one file failing must not abort the rest, and its hash stays uncached
|
|
220
189
|
}
|
|
221
190
|
}
|
|
222
191
|
if (dirty)
|
|
223
192
|
deps.saveCache(cache);
|
|
224
193
|
}
|
|
225
194
|
catch {
|
|
226
|
-
// best-effort
|
|
227
195
|
}
|
|
228
196
|
finally {
|
|
229
197
|
try {
|
|
230
198
|
fs.unlinkSync(jobFile);
|
|
231
199
|
}
|
|
232
|
-
catch {
|
|
200
|
+
catch { }
|
|
233
201
|
}
|
|
234
202
|
}
|
|
@@ -47,12 +47,6 @@ const DEFAULT_NORMALIZE_DEPS = {
|
|
|
47
47
|
homedir: () => os.homedir(),
|
|
48
48
|
realpathSync: (value) => fs.realpathSync.native(value),
|
|
49
49
|
};
|
|
50
|
-
/**
|
|
51
|
-
* Convert a user-entered path into the canonical absolute form persisted in
|
|
52
|
-
* ~/.codiedev/config.json. Existing symlinks resolve to their real target so a
|
|
53
|
-
* session cannot bypass an exclusion by entering the same folder through a
|
|
54
|
-
* different alias. Non-existent paths still normalize deterministically.
|
|
55
|
-
*/
|
|
56
50
|
function normalizeCapturePath(input, deps = {}) {
|
|
57
51
|
const d = { ...DEFAULT_NORMALIZE_DEPS, ...deps };
|
|
58
52
|
const trimmed = input.trim();
|
|
@@ -94,7 +88,6 @@ function findMatchingCaptureExclusion(candidate, ignoredPaths) {
|
|
|
94
88
|
function isCaptureIgnored(candidate, ignoredPaths) {
|
|
95
89
|
return findMatchingCaptureExclusion(candidate, ignoredPaths) !== null;
|
|
96
90
|
}
|
|
97
|
-
/** Every local root associated with a hook event, deduped in stable order. */
|
|
98
91
|
function captureScopePaths(input, fallback = process.cwd()) {
|
|
99
92
|
const values = [input.cwd, ...(input.workspace_roots ?? [])]
|
|
100
93
|
.filter((value) => typeof value === "string" && value.length > 0);
|
|
@@ -103,11 +96,6 @@ function captureScopePaths(input, fallback = process.cwd()) {
|
|
|
103
96
|
function shouldIgnoreCaptureInput(config, input, fallback) {
|
|
104
97
|
return isCaptureIgnored(captureScopePaths(input, fallback), config?.captureIgnoredPaths);
|
|
105
98
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Final egress decision for queued transcript uploads. Jobs written by older
|
|
108
|
-
* CLI versions have no scope metadata; once any exclusion exists, dropping
|
|
109
|
-
* those legacy jobs is safer than guessing that they are public.
|
|
110
|
-
*/
|
|
111
99
|
function shouldSkipCaptureUploadJob(job, config) {
|
|
112
100
|
const ignoredPaths = config?.captureIgnoredPaths ?? [];
|
|
113
101
|
if (ignoredPaths.length === 0)
|
package/dist/captureLog.js
CHANGED
|
@@ -36,47 +36,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.CAPTURE_LOG_PATH = exports.CAPTURE_LOG_MAX_BYTES = void 0;
|
|
37
37
|
exports.appendCaptureLog = appendCaptureLog;
|
|
38
38
|
exports.readCaptureLogTail = readCaptureLogTail;
|
|
39
|
-
// Rotating capture diagnostic log at <codiedevStateDir()>/capture.log
|
|
40
|
-
// (~/.codiedev by default; ~/.codiedev-desktop under CODIEDEV_CONFIG_DIR).
|
|
41
|
-
//
|
|
42
|
-
// Every stage of the capture pipeline (capture-finish child, upload child,
|
|
43
|
-
// stderr from detached children) writes one line here so users — and
|
|
44
|
-
// `codiedev doctor` — can see exactly why a capture succeeded or failed
|
|
45
|
-
// without having to file a support ticket or grep /tmp. The log is
|
|
46
|
-
// best-effort throughout: appendCaptureLog NEVER throws into the hook path.
|
|
47
39
|
const fs = __importStar(require("fs"));
|
|
48
40
|
const path = __importStar(require("path"));
|
|
49
|
-
// Imported from configDir (not utils) — utils.ts imports this module, so
|
|
50
|
-
// importing utils.ts here would create a circular import.
|
|
51
41
|
const configDir_1 = require("./configDir");
|
|
52
|
-
// Rotate the log when it exceeds this size (keeps the newest ~half). 256 KB
|
|
53
|
-
// holds thousands of entries; a user would have to see hundreds of errors
|
|
54
|
-
// before hitting this limit, so it's a generous safety valve.
|
|
55
42
|
exports.CAPTURE_LOG_MAX_BYTES = 256 * 1024;
|
|
56
43
|
exports.CAPTURE_LOG_PATH = path.join((0, configDir_1.codiedevStateDir)(), "capture.log");
|
|
57
|
-
// Append one structured line to the capture log. Rotates the file when it
|
|
58
|
-
// grows past CAPTURE_LOG_MAX_BYTES by truncating it to the newest half (keeps
|
|
59
|
-
// the most recent diagnostics). Best-effort: NEVER throws into the hook path —
|
|
60
|
-
// a diagnostic log must not break the thing it's diagnosing.
|
|
61
44
|
function appendCaptureLog(entry, deps = fs, filePath = exports.CAPTURE_LOG_PATH, now = Date.now) {
|
|
62
45
|
try {
|
|
63
46
|
const dir = path.dirname(filePath);
|
|
64
47
|
if (!deps.existsSync(dir))
|
|
65
48
|
deps.mkdirSync(dir, { recursive: true });
|
|
66
49
|
if (deps.existsSync(filePath) && deps.statSync(filePath).size >= exports.CAPTURE_LOG_MAX_BYTES) {
|
|
67
|
-
// Rotation: keep the newest half. Slice from the middle to a line
|
|
68
|
-
// boundary so we never persist a torn (partial) first line.
|
|
69
50
|
const content = deps.readFileSync(filePath, "utf8");
|
|
70
51
|
const half = content.slice(Math.floor(content.length / 2));
|
|
71
52
|
const firstNewline = half.indexOf("\n");
|
|
72
|
-
// If no newline is found in the second half, drop it all and start
|
|
73
|
-
// fresh — better than keeping a torn line.
|
|
74
53
|
const fromLine = firstNewline === -1 ? "" : half.slice(firstNewline + 1);
|
|
75
54
|
deps.writeFileSync(filePath, fromLine);
|
|
76
55
|
}
|
|
77
|
-
// Sanitize before composing: error messages can carry newlines (e.g. a
|
|
78
|
-
// proxy's HTML error body in err.message) which would tear the one-line
|
|
79
|
-
// format that doctor's parser depends on.
|
|
80
56
|
const outcome = entry.outcome.replace(/\s+/g, " ").trim();
|
|
81
57
|
const detail = entry.detail?.replace(/\s+/g, " ").trim();
|
|
82
58
|
const line = `${new Date(now()).toISOString()} ${entry.stage} session=${entry.sessionId ?? "-"} ${outcome}` +
|
|
@@ -85,12 +61,8 @@ function appendCaptureLog(entry, deps = fs, filePath = exports.CAPTURE_LOG_PATH,
|
|
|
85
61
|
deps.appendFileSync(filePath, line);
|
|
86
62
|
}
|
|
87
63
|
catch {
|
|
88
|
-
// diagnostics must never break capture
|
|
89
64
|
}
|
|
90
65
|
}
|
|
91
|
-
// Read the last N lines from the capture log. Returns [] when the file is
|
|
92
|
-
// missing or unreadable — callers (e.g. `codiedev doctor`) treat that as
|
|
93
|
-
// "nothing captured yet" rather than an error.
|
|
94
66
|
function readCaptureLogTail(n, deps = fs, filePath = exports.CAPTURE_LOG_PATH) {
|
|
95
67
|
try {
|
|
96
68
|
if (!deps.existsSync(filePath))
|
package/dist/captureReadiness.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Capture-readiness assessment for `codiedev doctor`. Answers: "from where I'm
|
|
3
|
-
// standing right now, would a session actually capture?" — the blind spot that
|
|
4
|
-
// let lean-container / submodule / no-remote setups silently produce zero
|
|
5
|
-
// sessions while doctor's setup checks stayed green.
|
|
6
|
-
//
|
|
7
|
-
// Pure + fully injectable so it unit-tests without a real git repo. The default
|
|
8
|
-
// production wiring (in doctor.ts) plugs in execSync + the real git helpers.
|
|
9
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
3
|
exports.assessCaptureReadiness = assessCaptureReadiness;
|
|
11
4
|
exports.assessWorkspaceRepos = assessWorkspaceRepos;
|
|
@@ -79,9 +72,6 @@ function formatRelativeAge(ms) {
|
|
|
79
72
|
return `${h}h`;
|
|
80
73
|
return `${Math.floor(h / 24)}d`;
|
|
81
74
|
}
|
|
82
|
-
// Turns the "last session captured" breadcrumb into a doctor line. This is the
|
|
83
|
-
// payoff check: it lets a user confirm their most recent session actually
|
|
84
|
-
// landed without opening the portal.
|
|
85
75
|
function summarizeLastCapture(record, nowMs) {
|
|
86
76
|
if (!record) {
|
|
87
77
|
return {
|
|
@@ -98,44 +88,18 @@ function summarizeLastCapture(record, nowMs) {
|
|
|
98
88
|
}
|
|
99
89
|
return { status: "pass", detail: `last session captured ${age} ago` };
|
|
100
90
|
}
|
|
101
|
-
// ─── Capture-activity summarizer ──────────────────────────────────────────────
|
|
102
|
-
// Turns the tail of ~/.codiedev/capture.log + a count of pending upload jobs
|
|
103
|
-
// into a single doctor CheckResult-compatible {status, detail} object.
|
|
104
|
-
//
|
|
105
|
-
// Pure (no fs/os imports) — all data is injected by the caller (doctor.ts).
|
|
106
91
|
const LOG_PATH_SUFFIX = "— details: ~/.codiedev/capture.log";
|
|
107
|
-
// Parse regex: a valid structured line has exactly this shape.
|
|
108
|
-
// stage is one of the four known stages; outcome is a bare token.
|
|
109
92
|
const STRUCTURED_LINE_RE = /^(\S+) (capture-finish|upload|upload-deferred|stderr) session=(\S+) (\S+)(?: (.*))?$/;
|
|
110
|
-
/** Append the shared log-path suffix to any detail string. */
|
|
111
93
|
function withSuffix(detail) {
|
|
112
94
|
return `${detail} ${LOG_PATH_SUFFIX}`;
|
|
113
95
|
}
|
|
114
|
-
// TLS-trust failure phrases/codes. These are what Node's TLS stack emits when
|
|
115
|
-
// a middlebox (corporate proxy / VPN doing SSL inspection) re-signs HTTPS with
|
|
116
|
-
// an internal CA. Node uses its OWN bundled Mozilla trust store — NOT the OS
|
|
117
|
-
// store — so installing the corporate cert system-wide fixes curl/git but not
|
|
118
|
-
// the CLI; only NODE_EXTRA_CA_CERTS (or an IT bypass) does.
|
|
119
|
-
// "certificate has expired" is deliberately absent: that's a server-side cert
|
|
120
|
-
// problem, not interception, and the hint would mislead.
|
|
121
96
|
const TLS_INTERCEPTION_RE = /self.signed certificate|unable to verify the first certificate|unable to get local issuer certificate|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|DEPTH_ZERO_SELF_SIGNED_CERT|CERT_UNTRUSTED/i;
|
|
122
|
-
/**
|
|
123
|
-
* When `text` (an error message, code, or capture.log detail) indicates a
|
|
124
|
-
* TLS-trust failure from SSL inspection, return the remediation hint;
|
|
125
|
-
* otherwise null.
|
|
126
|
-
*/
|
|
127
97
|
function tlsInterceptionHint(text, host) {
|
|
128
98
|
if (!TLS_INTERCEPTION_RE.test(text))
|
|
129
99
|
return null;
|
|
130
100
|
return (`your network is intercepting TLS (corporate proxy/VPN). Node ignores the OS cert store — ` +
|
|
131
101
|
`set NODE_EXTRA_CA_CERTS to your corporate root CA file, or ask IT to bypass SSL inspection for ${host}`);
|
|
132
102
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Flatten a thrown fetch/network error to "<message> (<code>)". undici's
|
|
135
|
-
* fetch wraps the real failure in TypeError("fetch failed") with the cause
|
|
136
|
-
* chained on `.cause` — walk to the deepest cause so doctor reports the
|
|
137
|
-
* actual TLS/socket error instead of "fetch failed".
|
|
138
|
-
*/
|
|
139
103
|
function describeFetchError(err) {
|
|
140
104
|
if (err === null || err === undefined)
|
|
141
105
|
return "unknown error";
|
|
@@ -148,10 +112,6 @@ function describeFetchError(err) {
|
|
|
148
112
|
const message = deepest.message || String(deepest);
|
|
149
113
|
return deepest.code ? `${message} (${deepest.code})` : message;
|
|
150
114
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Derive remediation text and status from the last failing upload-ish line.
|
|
153
|
-
* Inspects the `detail` field of the log line for http= and msg= tokens.
|
|
154
|
-
*/
|
|
155
115
|
function failDetail(rawLine, outcome, lineDetail, backendHost) {
|
|
156
116
|
const d = lineDetail ?? "";
|
|
157
117
|
const truncated = rawLine.slice(0, 140);
|
|
@@ -182,10 +142,7 @@ function summarizeCaptureActivity(tailLines, pendingJobCount, backendHost) {
|
|
|
182
142
|
continue;
|
|
183
143
|
parsed.push({ ts: m[1], stage: m[2], session: m[3], outcome: m[4], detail: m[5], raw });
|
|
184
144
|
}
|
|
185
|
-
// We care only about upload-ish lines (upload + upload-deferred).
|
|
186
|
-
// capture-finish and stderr lines are noise for this check.
|
|
187
145
|
const uploadLines = parsed.filter((p) => p.stage === "upload" || p.stage === "upload-deferred");
|
|
188
|
-
// Rule 1: last upload-ish outcome is ERROR or gave-up → fail.
|
|
189
146
|
if (uploadLines.length > 0) {
|
|
190
147
|
const last = uploadLines[uploadLines.length - 1];
|
|
191
148
|
if (last.outcome === "ERROR" || last.outcome === "gave-up") {
|
|
@@ -195,8 +152,6 @@ function summarizeCaptureActivity(tailLines, pendingJobCount, backendHost) {
|
|
|
195
152
|
};
|
|
196
153
|
}
|
|
197
154
|
}
|
|
198
|
-
// Rule 2: pending jobs queued (last upload wasn't a hard failure, but
|
|
199
|
-
// retries are still in-flight).
|
|
200
155
|
if (pendingJobCount > 0) {
|
|
201
156
|
const noun = pendingJobCount === 1 ? "job" : "jobs";
|
|
202
157
|
return {
|
|
@@ -204,15 +159,12 @@ function summarizeCaptureActivity(tailLines, pendingJobCount, backendHost) {
|
|
|
204
159
|
detail: withSuffix(`${pendingJobCount} pending upload ${noun} queued for retry`),
|
|
205
160
|
};
|
|
206
161
|
}
|
|
207
|
-
// Rule 3: no upload-ish lines at all — log is empty, garbage-only, or
|
|
208
|
-
// only capture-finish lines.
|
|
209
162
|
if (uploadLines.length === 0) {
|
|
210
163
|
return {
|
|
211
164
|
status: "warn",
|
|
212
165
|
detail: withSuffix("no capture uploads recorded yet on this machine — run a session and exit, then re-run `codiedev doctor`"),
|
|
213
166
|
};
|
|
214
167
|
}
|
|
215
|
-
// Rule 4a: last upload-ish outcome is ok → pass.
|
|
216
168
|
const last = uploadLines[uploadLines.length - 1];
|
|
217
169
|
const hasOk = uploadLines.some((p) => p.outcome === "ok");
|
|
218
170
|
if (last.outcome === "ok") {
|
|
@@ -221,9 +173,6 @@ function summarizeCaptureActivity(tailLines, pendingJobCount, backendHost) {
|
|
|
221
173
|
detail: withSuffix(`last upload ok (${last.ts})`),
|
|
222
174
|
};
|
|
223
175
|
}
|
|
224
|
-
// Rule 4b: last is "deferred" — a throttled/scheduled defer was queued.
|
|
225
|
-
// If an ok exists somewhere in the tail → pass (next upload deferred is fine).
|
|
226
|
-
// If no ok exists → warn.
|
|
227
176
|
if (last.outcome === "deferred") {
|
|
228
177
|
if (hasOk) {
|
|
229
178
|
return {
|
|
@@ -236,7 +185,6 @@ function summarizeCaptureActivity(tailLines, pendingJobCount, backendHost) {
|
|
|
236
185
|
detail: withSuffix("deferred uploads were superseded but no completed upload found in the recent log"),
|
|
237
186
|
};
|
|
238
187
|
}
|
|
239
|
-
// Rule 4c: last is "superseded".
|
|
240
188
|
if (hasOk) {
|
|
241
189
|
return {
|
|
242
190
|
status: "pass",
|
|
@@ -248,12 +196,7 @@ function summarizeCaptureActivity(tailLines, pendingJobCount, backendHost) {
|
|
|
248
196
|
detail: withSuffix("deferred uploads were superseded but no completed upload found in the recent log"),
|
|
249
197
|
};
|
|
250
198
|
}
|
|
251
|
-
// Server-confirmed "did my last session land?". Prefers portal truth over the
|
|
252
|
-
// local 2xx breadcrumb; the highest-value branch is "uploaded locally but the
|
|
253
|
-
// portal has no record" — the exact silent failure devs hit (2xx to nothing,
|
|
254
|
-
// wrong identity, backend drop).
|
|
255
199
|
function summarizeServerCapture(server, local, nowMs) {
|
|
256
|
-
// Endpoint unavailable / old backend → fall back to the local breadcrumb.
|
|
257
200
|
if (server === null)
|
|
258
201
|
return summarizeLastCapture(local, nowMs);
|
|
259
202
|
if (server.found && server.latest) {
|
|
@@ -266,7 +209,6 @@ function summarizeServerCapture(server, local, nowMs) {
|
|
|
266
209
|
}
|
|
267
210
|
return { status: "pass", detail: `confirmed in the portal — last session captured ${age} ago` };
|
|
268
211
|
}
|
|
269
|
-
// Server reachable but has no session for this user.
|
|
270
212
|
if (local) {
|
|
271
213
|
const age = formatRelativeAge(nowMs - local.recordedAt);
|
|
272
214
|
return {
|