@bitkyc08/opencodex 2.31.0 → 2.32.0
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/bin/ocx.mjs +99 -70
- package/gui/dist/assets/{index-DkcRs1fL.js → index-BJwu-ldX.js} +14 -14
- package/gui/dist/assets/index-DcBbHIAz.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +6 -8
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code.ts +2 -3
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/google.ts +6 -7
- package/src/adapters/kiro.ts +0 -3
- package/src/adapters/openai-responses.ts +3 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-web-search.ts +7 -2
- package/src/bridge.ts +21 -15
- package/src/cli/dispatch.ts +50 -2
- package/src/cli/doctor.ts +24 -11
- package/src/cli/help.ts +4 -3
- package/src/cli/index.ts +11 -4
- package/src/cli/models.ts +13 -3
- package/src/cli/observe.ts +20 -5
- package/src/cli/provider.ts +2 -1
- package/src/cli/registry.ts +7 -5
- package/src/cli/status.ts +2 -1
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +134 -0
- package/src/codex/app-server-processes.ts +3 -1
- package/src/codex/catalog/parsing.ts +18 -0
- package/src/codex/catalog/sync.ts +5 -4
- package/src/codex/desktop-app-restart.ts +342 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject.ts +49 -21
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/combos/failover.ts +27 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +81 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +308 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config.ts +75 -812
- package/src/generated/compatibility-version.json +135 -79
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +141 -0
- package/src/integrations/ownership.ts +10 -0
- package/src/integrations/state.ts +44 -5
- package/src/integrations/writer.ts +6 -0
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/process-control.ts +2 -1
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/health.ts +1 -1
- package/src/providers/registry.ts +1 -1
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/custom-tool-compat.ts +132 -38
- package/src/responses/parser.ts +3 -2
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/server/auth-cors.ts +9 -7
- package/src/server/index.ts +102 -21
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +1 -1
- package/src/server/management/config-routes.ts +4 -1
- package/src/server/management/context.ts +1 -1
- package/src/server/management/logs-usage-routes.ts +27 -6
- package/src/server/management/model-routes.ts +8 -4
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +2 -1
- package/src/server/request-log-conversation.ts +30 -0
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/combo-stream-preflight.ts +171 -0
- package/src/server/responses/compact.ts +6 -21
- package/src/server/responses/core.ts +225 -94
- package/src/server/responses/fetch-helpers.ts +2 -97
- package/src/server/responses-custom-tool-repair.ts +41 -5
- package/src/server/responses-undeclared-tool-guard.ts +156 -15
- package/src/service.ts +8 -4
- package/src/types/request.ts +6 -1
- package/src/types/tools.ts +87 -11
- package/src/types.ts +1 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/log.ts +16 -8
- package/src/usage/summary.ts +201 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/executor.ts +10 -3
- package/gui/dist/assets/index-CH7ncHCC.css +0 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human rendering for `ocx usage`.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of `observe.ts` and away from the shared `summaryLines()` helper on
|
|
5
|
+
* purpose. `summaryLines()` is a generic depth-1 flattener shared with
|
|
6
|
+
* storage/memory/debug/claude-inbound/injection; it renders any array as
|
|
7
|
+
* "N item(s)", which is why every per-model and per-provider cost the server
|
|
8
|
+
* computes used to vanish before reaching the terminal. Deepening it would
|
|
9
|
+
* change five unrelated commands.
|
|
10
|
+
*
|
|
11
|
+
* Formatting follows the existing CLI house style: dynamic `padEnd` columns
|
|
12
|
+
* (as in `formatAccountTable`), plain text, no ANSI colour.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface CostRow {
|
|
16
|
+
provider: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
requests: number;
|
|
19
|
+
totalTokens: number;
|
|
20
|
+
estimatedCostUsd?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UsageReportInput {
|
|
24
|
+
range?: string;
|
|
25
|
+
surface?: string;
|
|
26
|
+
since?: number | null;
|
|
27
|
+
summary?: {
|
|
28
|
+
requests?: number;
|
|
29
|
+
totalTokens?: number;
|
|
30
|
+
inputTokens?: number;
|
|
31
|
+
outputTokens?: number;
|
|
32
|
+
cachedInputTokens?: number;
|
|
33
|
+
estimatedCostUsd?: number;
|
|
34
|
+
unpricedRequests?: number;
|
|
35
|
+
unmeteredRequests?: number;
|
|
36
|
+
};
|
|
37
|
+
models?: CostRow[];
|
|
38
|
+
providers?: CostRow[];
|
|
39
|
+
days?: { date: string; requests: number; totalTokens: number; estimatedCostUsd?: number }[];
|
|
40
|
+
filter?: { provider: string | null; model: string | null; matched: boolean; comboOverlap: boolean };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MAX_MODEL_ROWS = 10;
|
|
44
|
+
|
|
45
|
+
function count(value: number | undefined): string {
|
|
46
|
+
return (value ?? 0).toLocaleString("en-US");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Matches the dashboard's `~$` with four fraction digits. Estimates below a
|
|
51
|
+
* hundredth of a cent still read as a number rather than collapsing to $0.00,
|
|
52
|
+
* which matters when a single request is being inspected.
|
|
53
|
+
*/
|
|
54
|
+
function usd(value: number | undefined): string {
|
|
55
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
56
|
+
return `~$${value.toFixed(4)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function table(header: string[], rows: string[][]): string[] {
|
|
60
|
+
if (rows.length === 0) return [];
|
|
61
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? "").length)));
|
|
62
|
+
const line = (cols: string[]): string => cols.map((c, i) => (c ?? "").padEnd(widths[i]!)).join(" ").trimEnd();
|
|
63
|
+
return [line(header), ...rows.map(line)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function describeScope(data: UsageReportInput): string {
|
|
67
|
+
const parts = [`Usage — ${data.range ?? "?"}`];
|
|
68
|
+
if (data.surface && data.surface !== "all") parts.push(`surface=${data.surface}`);
|
|
69
|
+
if (data.filter?.provider) parts.push(`provider=${data.filter.provider}`);
|
|
70
|
+
if (data.filter?.model) parts.push(`model=${data.filter.model}`);
|
|
71
|
+
return parts.join(", ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatUsageReport(data: UsageReportInput): string[] {
|
|
75
|
+
const summary = data.summary ?? {};
|
|
76
|
+
const lines: string[] = [describeScope(data), ""];
|
|
77
|
+
|
|
78
|
+
if (data.filter && !data.filter.matched) {
|
|
79
|
+
const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`]
|
|
80
|
+
.filter(Boolean).join(" and ");
|
|
81
|
+
lines.push(`No usage recorded for ${what} in this range.`);
|
|
82
|
+
lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const tokenSplit = [
|
|
87
|
+
summary.inputTokens !== undefined ? `in ${count(summary.inputTokens)}` : null,
|
|
88
|
+
summary.outputTokens !== undefined ? `out ${count(summary.outputTokens)}` : null,
|
|
89
|
+
summary.cachedInputTokens ? `cached ${count(summary.cachedInputTokens)}` : null,
|
|
90
|
+
].filter(Boolean).join(" / ");
|
|
91
|
+
|
|
92
|
+
lines.push(`Requests ${count(summary.requests)}`);
|
|
93
|
+
lines.push(`Tokens ${count(summary.totalTokens)}${tokenSplit ? ` (${tokenSplit})` : ""}`);
|
|
94
|
+
lines.push(`Est. cost ${usd(summary.estimatedCostUsd)} API list-price equivalent (this range)`);
|
|
95
|
+
|
|
96
|
+
const unpriced = summary.unpricedRequests ?? 0;
|
|
97
|
+
const unmetered = summary.unmeteredRequests ?? 0;
|
|
98
|
+
if (unpriced > 0 || unmetered > 0) {
|
|
99
|
+
// Spelled out because a $0 total is ambiguous otherwise: it can mean "no
|
|
100
|
+
// spend" or "no price row matched", and those are very different answers.
|
|
101
|
+
lines.push(` ${count(unpriced)} unpriced, ${count(unmetered)} unmetered excluded from ~$`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const providers = (data.providers ?? []).filter(row => row.requests > 0);
|
|
105
|
+
if (providers.length > 0) {
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push(...table(
|
|
108
|
+
["PROVIDER", "REQUESTS", "TOKENS", "EST. COST"],
|
|
109
|
+
providers.map(row => [row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]),
|
|
110
|
+
));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const models = (data.models ?? []).filter(row => row.requests > 0);
|
|
114
|
+
if (models.length > 0) {
|
|
115
|
+
lines.push("");
|
|
116
|
+
const shown = models.slice(0, MAX_MODEL_ROWS);
|
|
117
|
+
lines.push(...table(
|
|
118
|
+
["MODEL", "PROVIDER", "REQUESTS", "TOKENS", "EST. COST"],
|
|
119
|
+
shown.map(row => [row.model ?? "-", row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]),
|
|
120
|
+
));
|
|
121
|
+
if (models.length > shown.length) {
|
|
122
|
+
lines.push(`... ${models.length - shown.length} more (use --json)`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (data.filter?.comboOverlap) {
|
|
127
|
+
lines.push("");
|
|
128
|
+
lines.push("Some requests ran as combos, so per-model request counts can overlap. Cost does not.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push("Not a billing receipt. Subscription usage or provider credits may apply instead.");
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
import { readCodexCatalogPath } from "./catalog/parsing";
|
|
18
18
|
|
|
19
19
|
export const STALE_CODEX_APP_SERVER_HINT =
|
|
20
|
-
"If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex)."
|
|
20
|
+
"If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex). "
|
|
21
|
+
+ "On Windows the desktop app itself may also need a full restart (ocx sync --restart-desktop-app).";
|
|
21
22
|
|
|
22
23
|
/** Attach the shared dashboard hint only after a catalog or models_cache write. */
|
|
23
24
|
export function attachStaleAppServerHint<T extends {
|
|
@@ -505,6 +506,7 @@ export function formatStaleCodexAppServerWarning(
|
|
|
505
506
|
`WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
|
|
506
507
|
+ "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. "
|
|
507
508
|
+ "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to send SIGTERM only to matching app-server processes. "
|
|
509
|
+
+ "On Windows the desktop app itself may also need a full restart (`ocx sync --restart-desktop-app`). "
|
|
508
510
|
+ "Active turns may be interrupted."
|
|
509
511
|
);
|
|
510
512
|
}
|
|
@@ -149,6 +149,9 @@ export const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go", "deepse
|
|
|
149
149
|
export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
|
|
150
150
|
// Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
|
|
151
151
|
"opencode-go/hy3-preview",
|
|
152
|
+
// Issue #2330: OpenCode Go models absent from current documentation or returning terminal HTTP 400 errors.
|
|
153
|
+
"opencode-go/mimo-v2-omni",
|
|
154
|
+
"opencode-go/mimo-v2-pro",
|
|
152
155
|
]);
|
|
153
156
|
|
|
154
157
|
export function isRoutedModelCompatibilityExcluded(slug: string): boolean {
|
|
@@ -191,6 +194,8 @@ export function shouldExposeRoutedModel(model: CatalogModel): boolean {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
export function readCodexCatalogPath(): string {
|
|
197
|
+
const home = activeCodexHome();
|
|
198
|
+
if (home) return readCodexCatalogPathForHome(home);
|
|
194
199
|
try {
|
|
195
200
|
const configPath = activeCodexConfigPath();
|
|
196
201
|
if (existsSync(configPath)) {
|
|
@@ -202,6 +207,19 @@ export function readCodexCatalogPath(): string {
|
|
|
202
207
|
return activeDefaultCatalogPath();
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
/** Resolve the configured catalog without consulting ambient CODEX_HOME again. */
|
|
211
|
+
export function readCodexCatalogPathForHome(codexHome: string): string {
|
|
212
|
+
try {
|
|
213
|
+
const configPath = join(codexHome, "config.toml");
|
|
214
|
+
if (existsSync(configPath)) {
|
|
215
|
+
const toml = readFileSync(configPath, "utf-8");
|
|
216
|
+
const path = readRootTomlString(toml, "model_catalog_json");
|
|
217
|
+
if (path) return resolve(codexHome, path);
|
|
218
|
+
}
|
|
219
|
+
} catch { /* ignore */ }
|
|
220
|
+
return join(codexHome, "opencodex-catalog.json");
|
|
221
|
+
}
|
|
222
|
+
|
|
205
223
|
export function parseCatalogJson(raw: string): RawCatalog | null {
|
|
206
224
|
try {
|
|
207
225
|
const cat = JSON.parse(raw);
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
} from "../model-entitlements";
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
|
|
44
|
+
import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing";
|
|
45
45
|
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
|
|
46
46
|
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
|
|
47
47
|
import {
|
|
@@ -1832,11 +1832,12 @@ export function invalidateCodexModelsCacheWithPermit(
|
|
|
1832
1832
|
// The catalog-only sync override applies here too so an explicit refresh
|
|
1833
1833
|
// keeps the cache consistent with the catalog it just wrote.
|
|
1834
1834
|
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
|
|
1835
|
-
const catalogPath =
|
|
1835
|
+
const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
|
|
1836
|
+
const cachePath = join(owningCodexHome, "models_cache.json");
|
|
1836
1837
|
if (!existsSync(catalogPath)) return false;
|
|
1837
1838
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
1838
1839
|
const models = catalog.models ?? catalog;
|
|
1839
|
-
const currentCache = readCatalog(
|
|
1840
|
+
const currentCache = readCatalog(cachePath);
|
|
1840
1841
|
const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
|
|
1841
1842
|
typeof entry.slug === "string" ? [entry.slug] : []));
|
|
1842
1843
|
const currentConfig = loadConfig();
|
|
@@ -1864,7 +1865,7 @@ export function invalidateCodexModelsCacheWithPermit(
|
|
|
1864
1865
|
models: [...models, ...observedAccountModels],
|
|
1865
1866
|
};
|
|
1866
1867
|
replaceCodexModelsCache(permit, owningCodexHome, {
|
|
1867
|
-
path:
|
|
1868
|
+
path: cachePath,
|
|
1868
1869
|
content: `${JSON.stringify(wrapper, null, 2)}\n`,
|
|
1869
1870
|
});
|
|
1870
1871
|
return true;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full restart of the Codex desktop app (the Electron shell), Windows only.
|
|
3
|
+
*
|
|
4
|
+
* `--restart-codex` deliberately signals only `codex app-server` /
|
|
5
|
+
* `codex-code-mode-host` processes: `isCodexAppServerCommandLine` requires a
|
|
6
|
+
* `codex` executable token, so the shell that owns the model picker is never a
|
|
7
|
+
* match. On macOS that is enough, because the respawned app-server re-emits
|
|
8
|
+
* `codex-app-server-initialized` and the renderer drops its cached
|
|
9
|
+
* `model/list`. On Windows MSIX it is not: externally terminating the child
|
|
10
|
+
* does not reliably re-emit that event in the surviving shell, so the picker
|
|
11
|
+
* keeps showing the old catalog until the app itself is restarted (#2292).
|
|
12
|
+
*
|
|
13
|
+
* This is therefore a SEPARATE opt-in flag rather than a widening of
|
|
14
|
+
* `--restart-codex`. Quitting the desktop app ends live conversations, which is
|
|
15
|
+
* a different consent from restarting a background helper, and the CLI contract
|
|
16
|
+
* for `--restart-codex` promises the narrow behavior.
|
|
17
|
+
*
|
|
18
|
+
* Everything here fails CLOSED: if the package cannot be identified, if a
|
|
19
|
+
* target is part of our own ancestry, or if any target survives termination,
|
|
20
|
+
* nothing is relaunched and the caller is told to restart manually. A stale
|
|
21
|
+
* picker is a much smaller problem than a wrongly killed process.
|
|
22
|
+
*/
|
|
23
|
+
import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
/** Bounded subprocess options. A hung Appx/CIM probe must never wedge `ocx sync`. */
|
|
27
|
+
export interface DesktopAppExecOptions {
|
|
28
|
+
timeout?: number;
|
|
29
|
+
windowsHide?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DesktopAppRestartIo {
|
|
33
|
+
platform?: NodeJS.Platform;
|
|
34
|
+
/** Returns stdout. Options are part of the seam so the timeout is testable. */
|
|
35
|
+
execFile?: (file: string, args: readonly string[], options?: DesktopAppExecOptions) => string;
|
|
36
|
+
/** Process ancestry of the current process, innermost first. Used for the self-kill guard. */
|
|
37
|
+
ancestryPids?: () => number[];
|
|
38
|
+
isAlive?: (pid: number) => boolean;
|
|
39
|
+
sleep?: (ms: number) => void;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DesktopAppRestartReason =
|
|
44
|
+
| "windows_only"
|
|
45
|
+
| "package_discovery_failed"
|
|
46
|
+
| "no_targets"
|
|
47
|
+
| "self_ancestry"
|
|
48
|
+
| "targets_survived";
|
|
49
|
+
|
|
50
|
+
export interface DesktopAppRestartResult {
|
|
51
|
+
attempted: boolean;
|
|
52
|
+
stopped: number[];
|
|
53
|
+
surviving: number[];
|
|
54
|
+
relaunch: "started" | "skipped";
|
|
55
|
+
reason?: DesktopAppRestartReason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How long a graceful close is given before the forced pass. */
|
|
59
|
+
const GRACEFUL_EXIT_TIMEOUT_MS = 15_000;
|
|
60
|
+
/** How long a forced kill is given before the target counts as surviving. */
|
|
61
|
+
const FORCED_EXIT_TIMEOUT_MS = 5_000;
|
|
62
|
+
/** Every probe is bounded; PowerShell module loading is the slow part. */
|
|
63
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
64
|
+
|
|
65
|
+
interface DesktopPackage {
|
|
66
|
+
family: string;
|
|
67
|
+
installLocation: string;
|
|
68
|
+
aumid: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Runtime discovery, never a hardcoded identifier. The beta MSIX package family
|
|
73
|
+
* changes between builds, so a literal AUMID would silently stop matching and
|
|
74
|
+
* then either do nothing or — worse — match a package we did not mean.
|
|
75
|
+
*/
|
|
76
|
+
function discoverPackage(exec: NonNullable<DesktopAppRestartIo["execFile"]>): DesktopPackage | null {
|
|
77
|
+
const script = [
|
|
78
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
79
|
+
"Import-Module Appx -ErrorAction SilentlyContinue",
|
|
80
|
+
"$p = Get-AppxPackage -Name OpenAI.Codex",
|
|
81
|
+
"if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }",
|
|
82
|
+
"if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {",
|
|
83
|
+
" $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"",
|
|
84
|
+
"}",
|
|
85
|
+
].join("; ");
|
|
86
|
+
let stdout: string;
|
|
87
|
+
try {
|
|
88
|
+
stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
89
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
|
|
96
|
+
if (lines.length < 3 || lines[0] === "MISS") return null;
|
|
97
|
+
const [family, installLocation, aumid] = lines;
|
|
98
|
+
if (!family || !installLocation || !aumid) return null;
|
|
99
|
+
return { family, installLocation, aumid };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface DesktopProcess {
|
|
103
|
+
pid: number;
|
|
104
|
+
parentPid: number;
|
|
105
|
+
/** Win32_Process CreationDate. Guards against PID reuse across the wait window. */
|
|
106
|
+
createdAt: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Only `ChatGPT.exe` processes whose image lives under the discovered install
|
|
111
|
+
* location AND owned by the current user. The install location alone is not
|
|
112
|
+
* enough: an MSIX package under `WindowsApps` is shared, so on a multi-user
|
|
113
|
+
* machine another account's Codex desktop matches the same path. The app-server
|
|
114
|
+
* collector already pays for `GetOwner` for exactly this reason.
|
|
115
|
+
*
|
|
116
|
+
* `CreationDate` is captured so a PID can be re-verified before it is signalled;
|
|
117
|
+
* a graceful-close window is long enough for Windows to recycle a PID.
|
|
118
|
+
*/
|
|
119
|
+
function listPackageProcesses(
|
|
120
|
+
exec: NonNullable<DesktopAppRestartIo["execFile"]>,
|
|
121
|
+
installLocation: string,
|
|
122
|
+
): DesktopProcess[] {
|
|
123
|
+
const literal = installLocation.replace(/'/g, "''");
|
|
124
|
+
const script = [
|
|
125
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
126
|
+
`$root = '${literal}'`,
|
|
127
|
+
"$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
|
|
128
|
+
"Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
|
|
129
|
+
" Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
|
|
130
|
+
" ForEach-Object {",
|
|
131
|
+
" $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
|
|
132
|
+
" if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
|
|
133
|
+
" $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }",
|
|
134
|
+
" if ($owner -ieq $me) {",
|
|
135
|
+
" \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))\"",
|
|
136
|
+
" }",
|
|
137
|
+
" }",
|
|
138
|
+
" }",
|
|
139
|
+
].join(" ");
|
|
140
|
+
let stdout: string;
|
|
141
|
+
try {
|
|
142
|
+
stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
143
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
});
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const processes: DesktopProcess[] = [];
|
|
150
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
151
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
|
|
152
|
+
if (!match) continue;
|
|
153
|
+
const pid = Number(match[1]);
|
|
154
|
+
const parentPid = Number(match[2]);
|
|
155
|
+
const createdAt = match[3]!;
|
|
156
|
+
if (Number.isSafeInteger(pid) && Number.isSafeInteger(parentPid)) {
|
|
157
|
+
processes.push({ pid, parentPid, createdAt });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return processes;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when the PID still names the same process we verified. Between listing
|
|
165
|
+
* and signalling there is a graceful-close window, and a `taskkill /T /F` on a
|
|
166
|
+
* recycled PID would tear down an unrelated process tree.
|
|
167
|
+
*/
|
|
168
|
+
function stillSameProcess(
|
|
169
|
+
exec: NonNullable<DesktopAppRestartIo["execFile"]>,
|
|
170
|
+
installLocation: string,
|
|
171
|
+
target: DesktopProcess,
|
|
172
|
+
): boolean {
|
|
173
|
+
const current = listPackageProcesses(exec, installLocation)
|
|
174
|
+
.find(p => p.pid === target.pid);
|
|
175
|
+
return current !== undefined && current.createdAt === target.createdAt;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Roots are the package processes whose parent is not itself in the package tree. */
|
|
179
|
+
function rootProcesses(processes: readonly DesktopProcess[]): DesktopProcess[] {
|
|
180
|
+
const inTree = new Set(processes.map(p => p.pid));
|
|
181
|
+
return processes.filter(p => !inTree.has(p.parentPid));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Full Windows parent chain for this process, innermost first.
|
|
186
|
+
*
|
|
187
|
+
* `process.ppid` is one level, which is not enough: a terminal hosted inside the
|
|
188
|
+
* desktop app sits several hops below `ChatGPT.exe`, so a one-level check would
|
|
189
|
+
* miss the exact case the guard exists for and we would terminate our own host.
|
|
190
|
+
* The chain therefore comes from CIM, with a bound so a corrupted parent cycle
|
|
191
|
+
* cannot spin.
|
|
192
|
+
*/
|
|
193
|
+
function windowsAncestryPids(exec: NonNullable<DesktopAppRestartIo["execFile"]>): number[] {
|
|
194
|
+
const chain: number[] = [process.pid];
|
|
195
|
+
let current = process.pid;
|
|
196
|
+
for (let hop = 0; hop < 16; hop++) {
|
|
197
|
+
let stdout: string;
|
|
198
|
+
try {
|
|
199
|
+
stdout = exec(resolveTrustedWindowsPowerShellExe(), [
|
|
200
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
201
|
+
`$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`,
|
|
202
|
+
], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
|
|
203
|
+
} catch {
|
|
204
|
+
// An unreadable chain must not be read as "not our ancestor".
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
const parent = Number(stdout.trim());
|
|
208
|
+
if (!Number.isSafeInteger(parent) || parent <= 0 || chain.includes(parent)) break;
|
|
209
|
+
chain.push(parent);
|
|
210
|
+
current = parent;
|
|
211
|
+
}
|
|
212
|
+
return chain;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function defaultExecFile(file: string, args: readonly string[], options?: DesktopAppExecOptions): string {
|
|
216
|
+
return execFileSync(file, [...args], {
|
|
217
|
+
encoding: "utf-8",
|
|
218
|
+
timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
|
|
219
|
+
windowsHide: options?.windowsHide ?? true,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function defaultIsAlive(pid: number): boolean {
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, 0);
|
|
226
|
+
return true;
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function defaultSleep(ms: number): void {
|
|
233
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function waitForExit(
|
|
237
|
+
pid: number,
|
|
238
|
+
timeoutMs: number,
|
|
239
|
+
isAlive: (pid: number) => boolean,
|
|
240
|
+
sleep: (ms: number) => void,
|
|
241
|
+
now: () => number,
|
|
242
|
+
): boolean {
|
|
243
|
+
const deadline = now() + timeoutMs;
|
|
244
|
+
while (now() < deadline) {
|
|
245
|
+
if (!isAlive(pid)) return true;
|
|
246
|
+
sleep(250);
|
|
247
|
+
}
|
|
248
|
+
return !isAlive(pid);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stop every package-tree root gracefully, force the stragglers, then relaunch
|
|
253
|
+
* through the discovered AUMID. Returns without relaunching if anything
|
|
254
|
+
* survived, because launching a second shell beside a stuck one is worse than
|
|
255
|
+
* leaving the user to restart it.
|
|
256
|
+
*/
|
|
257
|
+
export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult {
|
|
258
|
+
const platform = io.platform ?? process.platform;
|
|
259
|
+
const skipped = (reason: DesktopAppRestartReason): DesktopAppRestartResult => ({
|
|
260
|
+
attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason,
|
|
261
|
+
});
|
|
262
|
+
if (platform !== "win32") return skipped("windows_only");
|
|
263
|
+
|
|
264
|
+
const exec = io.execFile ?? defaultExecFile;
|
|
265
|
+
const pkg = discoverPackage(exec);
|
|
266
|
+
if (!pkg) return skipped("package_discovery_failed");
|
|
267
|
+
|
|
268
|
+
const processes = listPackageProcesses(exec, pkg.installLocation);
|
|
269
|
+
const roots = rootProcesses(processes);
|
|
270
|
+
if (roots.length === 0) return skipped("no_targets");
|
|
271
|
+
|
|
272
|
+
const ancestryPids = io.ancestryPids ? io.ancestryPids() : windowsAncestryPids(exec);
|
|
273
|
+
if (ancestryPids.length === 0) {
|
|
274
|
+
// Fail closed: an unreadable ancestry chain cannot prove we are outside the
|
|
275
|
+
// tree we are about to terminate.
|
|
276
|
+
return skipped("self_ancestry");
|
|
277
|
+
}
|
|
278
|
+
const ancestry = new Set(ancestryPids);
|
|
279
|
+
if (processes.some(p => ancestry.has(p.pid))) {
|
|
280
|
+
// Terminating our own tree would kill this command mid-flight and leave the
|
|
281
|
+
// user with neither a restarted app nor an explanation.
|
|
282
|
+
return skipped("self_ancestry");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isAlive = io.isAlive ?? defaultIsAlive;
|
|
286
|
+
const sleep = io.sleep ?? defaultSleep;
|
|
287
|
+
const now = io.now ?? (() => Date.now());
|
|
288
|
+
const stopped: number[] = [];
|
|
289
|
+
const surviving: number[] = [];
|
|
290
|
+
|
|
291
|
+
for (const root of roots) {
|
|
292
|
+
const pid = root.pid;
|
|
293
|
+
// Re-verify immediately before the graceful close: the listing is already
|
|
294
|
+
// one probe old.
|
|
295
|
+
if (!stillSameProcess(exec, pkg.installLocation, root)) {
|
|
296
|
+
stopped.push(pid);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
exec(resolveTrustedWindowsPowerShellExe(), [
|
|
301
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
302
|
+
`$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`,
|
|
303
|
+
], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
|
|
304
|
+
} catch {
|
|
305
|
+
/* a refused graceful close still gets the forced pass below */
|
|
306
|
+
}
|
|
307
|
+
if (waitForExit(pid, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) {
|
|
308
|
+
stopped.push(pid);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// The wait window is long enough for Windows to recycle a PID, and the next
|
|
312
|
+
// step is `/T /F` against a whole tree. Confirm the PID is still the process
|
|
313
|
+
// we verified, or leave it alone.
|
|
314
|
+
if (!stillSameProcess(exec, pkg.installLocation, root)) {
|
|
315
|
+
stopped.push(pid);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"], {
|
|
320
|
+
timeout: PROBE_TIMEOUT_MS, windowsHide: true,
|
|
321
|
+
});
|
|
322
|
+
} catch {
|
|
323
|
+
/* fall through to the liveness check: the process state decides, not the exit code */
|
|
324
|
+
}
|
|
325
|
+
if (waitForExit(pid, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) stopped.push(pid);
|
|
326
|
+
else surviving.push(pid);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (surviving.length > 0) {
|
|
330
|
+
return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
exec(resolveTrustedWindowsPowerShellExe(), [
|
|
335
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
336
|
+
`Start-Process 'shell:AppsFolder\\${pkg.aumid}'`,
|
|
337
|
+
], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
|
|
338
|
+
} catch {
|
|
339
|
+
return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
|
|
340
|
+
}
|
|
341
|
+
return { attempted: true, stopped, surviving, relaunch: "started" };
|
|
342
|
+
}
|
package/src/codex/history-job.ts
CHANGED
|
@@ -109,7 +109,8 @@ export type CodexHistoryJobOutcome =
|
|
|
109
109
|
| { readonly kind: "skipped" }
|
|
110
110
|
| { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
|
|
111
111
|
| { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
|
|
112
|
-
readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason
|
|
112
|
+
readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason;
|
|
113
|
+
readonly rows?: number; readonly files?: number };
|
|
113
114
|
|
|
114
115
|
/**
|
|
115
116
|
* Derive the durable history operation from admitted intent.
|
|
@@ -174,7 +175,14 @@ function isPlausibleWorkerResult(
|
|
|
174
175
|
|| message.reason === "desired_disabled" || message.reason === "desired_enabled";
|
|
175
176
|
case "error":
|
|
176
177
|
return typeof message.message === "string"
|
|
177
|
-
&& (message.
|
|
178
|
+
&& (message.rows === undefined || (Number.isSafeInteger(message.rows) && Number(message.rows) >= 0))
|
|
179
|
+
&& (message.files === undefined || (Number.isSafeInteger(message.files) && Number(message.files) >= 0))
|
|
180
|
+
&& ((message.rows === undefined && message.files === undefined)
|
|
181
|
+
|| (message.rows !== undefined && message.files !== undefined))
|
|
182
|
+
&& (message.reason === undefined
|
|
183
|
+
|| message.reason === "busy"
|
|
184
|
+
|| message.reason === "permission"
|
|
185
|
+
|| message.reason === "integrity");
|
|
178
186
|
default:
|
|
179
187
|
return false;
|
|
180
188
|
}
|
|
@@ -224,6 +232,7 @@ export function describeHistoryJobFailure(
|
|
|
224
232
|
: surface === "recover-legacy"
|
|
225
233
|
? "the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command."
|
|
226
234
|
: "the Codex app appears to be holding the history database. Close Codex and run `ocx restore` again.";
|
|
235
|
+
const busyStateText = "Codex history state is busy (database, backup manifest, or rollout file); this is not enough evidence to blame the Codex app. It is retried automatically while the proxy runs; run 'ocx doctor' before forcing another attempt.";
|
|
227
236
|
if (outcome.kind === "blocked") {
|
|
228
237
|
if (outcome.reason === "busy") return busyText;
|
|
229
238
|
switch (outcome.reason) {
|
|
@@ -237,10 +246,22 @@ export function describeHistoryJobFailure(
|
|
|
237
246
|
return "Codex integration is enabled, so the history operation was skipped.";
|
|
238
247
|
}
|
|
239
248
|
}
|
|
240
|
-
|
|
249
|
+
const partiallyChanged = (outcome.rows ?? 0) > 0 || (outcome.files ?? 0) > 0;
|
|
250
|
+
if (partiallyChanged && outcome.historyFailureReason === "busy") {
|
|
251
|
+
return "Codex history metadata changed but did not converge because manifest finalization remained busy; the manifest was retained for review and safe retry. Run 'ocx doctor'.";
|
|
252
|
+
}
|
|
253
|
+
if (partiallyChanged && outcome.historyFailureReason === "permission") {
|
|
254
|
+
return "Codex history metadata changed but did not converge because permission was denied while finalizing the manifest; the manifest was retained for review and safe retry. Run 'ocx doctor'.";
|
|
255
|
+
}
|
|
256
|
+
if (outcome.historyFailureReason === "busy") return busyStateText;
|
|
241
257
|
if (outcome.historyFailureReason === "permission") {
|
|
242
258
|
return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'.";
|
|
243
259
|
}
|
|
260
|
+
if (outcome.historyFailureReason === "integrity") {
|
|
261
|
+
return partiallyChanged
|
|
262
|
+
? "the history backup or its restore target changed after a partial restore; the manifest was retained for review and safe retry. Run 'ocx doctor'."
|
|
263
|
+
: "the history backup or its restore target failed integrity checks; no unverified provider metadata was applied. Run 'ocx doctor'.";
|
|
264
|
+
}
|
|
244
265
|
switch (outcome.reason) {
|
|
245
266
|
case "worker-error":
|
|
246
267
|
return `the history worker failed (${outcome.message}). Run 'ocx doctor'.`;
|
|
@@ -277,6 +298,9 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
|
|
|
277
298
|
reason: "worker-error",
|
|
278
299
|
message: redactWorkerMessage(result.message),
|
|
279
300
|
...(result.reason ? { historyFailureReason: result.reason } : {}),
|
|
301
|
+
...(result.rows !== undefined && result.files !== undefined
|
|
302
|
+
? { rows: result.rows, files: result.files }
|
|
303
|
+
: {}),
|
|
280
304
|
};
|
|
281
305
|
}
|
|
282
306
|
return result.outcome === "skipped"
|
|
@@ -284,6 +308,11 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
|
|
|
284
308
|
: { kind: "converged", rows: result.rows, files: result.files, ...(result.proof ? { proof: result.proof } : {}) };
|
|
285
309
|
}
|
|
286
310
|
|
|
311
|
+
/** Test seam for the parent-side Worker result classification contract. */
|
|
312
|
+
export function classifyWorkerResultForTests(result: HistoryWorkerResult): CodexHistoryJobOutcome {
|
|
313
|
+
return classifyWorkerResult(result);
|
|
314
|
+
}
|
|
315
|
+
|
|
287
316
|
/**
|
|
288
317
|
* Run one history unit in a Worker and join it before returning.
|
|
289
318
|
*
|