Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2076,10 +2076,17 @@ const usageStatisticsSchema = z.object({
consent: z.enum(["default", "enabled", "disabled"]),
sending: z.boolean(), blocked_by: z.string().nullable(), endpoint: z.string().nullable(),
policy: z.string(), notice_required: z.boolean(),
notice: z.object({ version: z.number(), endpoint: z.string(), policy: z.string() }),
automatic_notice_required: z.boolean(),
next_payload: z.unknown(), aggregate_preview: z.unknown(), goal_preview: z.unknown(),
});
export type UsageStatistics = z.infer<typeof usageStatisticsSchema>;
export async function usageStatistics(enabled?: boolean): Promise<UsageStatistics> {
return usageStatisticsSchema.parse(await requestJson<unknown>("/api/chat/usage-statistics",
enabled === undefined ? undefined : { method: "POST", body: JSON.stringify({ enabled }) }));
}

export async function acknowledgeUsageNotice(notice: UsageStatistics["notice"]): Promise<UsageStatistics> {
return usageStatisticsSchema.parse(await requestJson<unknown>("/api/chat/usage-statistics",
{ method: "POST", body: JSON.stringify({ notice }) }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { WorkspaceActionForm, type WorkspaceActionDraft } from "./workspace-acti
import { GoalActivityChip, GoalIdentityMark } from "./goal-activity-view";
import { ManagerBrief } from "./manager-brief";
import { WorkspaceSettingsPage } from "./workspace-settings-page";
import { UsageStatisticsNotice } from "./usage-statistics-notice";
import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme";
import { compareProposalRecency } from "./proposal-recency";
import { WorkspaceShell } from "./workspace-shell";
Expand Down Expand Up @@ -1679,6 +1680,7 @@ export function PersonalWorkspacePage({
theme={theme}
main={(
<div className="personal-channel">
<div>
<ChannelHeader
agents={agents}
managerChatOpen={managerChatOpen}
Expand Down Expand Up @@ -1708,6 +1710,8 @@ export function PersonalWorkspacePage({
selectedGoal={selectedGoal}
selectedGoalTab={selectedGoalTab}
/>
{!readOnly ? <UsageStatisticsNotice onDetails={() => openSettings({ kind: "settings", tab: "machine" })} /> : null}
</div>
{selectedGoalId && selectedGoalTab === "chat" && !readOnly && selectedAgentId === "codex" && callbacks.onStartLoopX ? <GoalLoopXMode
onPrepare={() => callbacks.onPrepareLoopX!(selectedAgentId, selectedGoalId)}
key={`${selectedGoalId}:${selectedAgentId}`} sessionId={conversationSessionId} onChange={setLoopxMode}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { acknowledgeUsageNotice, usageStatistics, type UsageStatistics } from "../../data/chat";
import { useWorkspaceI18n } from "./i18n";

/** Show the disclosure on the live App before acknowledging the shared policy. */
export function UsageStatisticsNotice({ onDetails }: { onDetails: () => void }) {
const { locale } = useWorkspaceI18n();
const zh = locale === "zh-CN";
const [state, setState] = useState<UsageStatistics | null>(null);
const [dismissed, setDismissed] = useState(false);
const [error, setError] = useState(false);
const [disabling, setDisabling] = useState(false);
const panel = useRef<HTMLElement>(null);
const attempted = useRef(false);
const choice = useRef(0);
useEffect(() => {
let active = true;
usageStatistics().then(value => {
if (active && value.automatic_notice_required) setState(value);
}).catch(() => {}); // An unavailable settings service cannot authorize collection.
return () => { active = false; };
}, []);
useEffect(() => {
if (!state?.automatic_notice_required || dismissed || !panel.current) return;
let active = true;
let frame = 0;
const element = panel.current;
const acknowledge = () => {
cancelAnimationFrame(frame);
if (document.visibilityState !== "visible" || !element.getClientRects().length) return;
// Two frames let the visible disclosure paint before changing local state.
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (!active || attempted.current || document.visibilityState !== "visible"
|| !element.getClientRects().length) return;
const rect = element.getBoundingClientRect();
if (rect.bottom <= 0 || rect.top >= window.innerHeight) return;
attempted.current = true;
const revision = choice.current;
acknowledgeUsageNotice(state.notice).then(value => {
if (active && revision === choice.current) setState(value);
}).catch(() => { if (active && revision === choice.current) setError(true); });
});
});
};
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) acknowledge();
});
observer.observe(element);
document.addEventListener("visibilitychange", acknowledge);
return () => {
active = false;
cancelAnimationFrame(frame);
observer.disconnect();
document.removeEventListener("visibilitychange", acknowledge);
};
}, [state, dismissed]);

async function disable() {
choice.current++;
attempted.current = true;
setDisabling(true);
setError(false);
try { setState(await usageStatistics(false)); }
catch { setError(true); }
finally { setDisabling(false); }
}
if (!state || dismissed) return null;
return <section ref={panel} className="personal-usage-notice" aria-label={zh ? "基础使用统计" : "Basic usage statistics"} data-testid="usage-statistics-notice">
<div>
<strong role="status">{state.consent === "disabled" ? (zh ? "基础使用统计已关闭" : "Basic usage statistics disabled")
: state.sending ? (zh ? "基础使用统计已开启" : "Basic usage statistics enabled")
: state.automatic_notice_required ? (zh ? "基础使用统计 · 告知后自动开启" : "Basic usage statistics · enabled after this notice")
: (zh ? "基础使用统计当前不发送,请查看详情" : "Basic usage statistics are not sending; see details")}</strong>
<p>{zh
? "用于改进平台支持与使用体验。发送随机安装标识和环境信息,以及另行汇总的 CLI 使用次数、结果、耗时和 Goal 时长区间;不采集对话、代码、路径或命令参数。可随时关闭。"
: "Helps improve platform support and usage. Sends a random installation ID and environment information, plus separate CLI usage, result, timing and Goal duration summaries. No conversations, code, paths or command arguments. You can turn it off at any time."}</p>
<p className="personal-usage-recipient">{zh ? "接收方:" : "Recipient: "}{state.endpoint}</p>
{error ? <p role="alert">{zh ? "设置未能保存,请打开详情重试。" : "Could not save this setting. Open details to retry."}</p> : null}
</div>
<div className="personal-usage-notice-actions">
{state.consent !== "disabled" ? <button type="button" disabled={disabling} onClick={() => void disable()}>{zh ? "关闭统计" : "Turn off"}</button> : null}
<button type="button" onClick={() => { setDismissed(true); onDetails(); }}>{zh ? "了解详情" : "Details"}</button>
<button type="button" onClick={() => setDismissed(true)} aria-label={zh ? "收起统计告知" : "Dismiss statistics notice"}>{zh ? "收起" : "Dismiss"}</button>
</div>
</section>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ export function UsageStatisticsSettings() {
finally { setBusy(false); }
}
return <details className="personal-capability-scope-note personal-usage-statistics" data-testid="usage-statistics-settings">
<summary>{zh ? "基础使用统计 · 默认开启,可关闭" : "Basic usage statistics · on by default, optional"}</summary>
<summary>{zh ? "基础使用统计 · 告知后默认开启,可关闭" : "Basic usage statistics · on after notice, optional"}</summary>
<p>{zh
? "用于决定平台支持和改进命令体验。每天向 LoopX 的 Cloudflare 收集服务发送随机安装标识、版本、系统、CPU 架构、Python 版本和安装渠道;固定的 CLI 功能、结果、耗时区间和错误类别在本机按天汇总后另行发送,不带安装标识。"
: "Helps prioritize platform support and CLI improvements. A daily heartbeat sends a random installation ID, version, OS, CPU architecture, Python version and install channel to the LoopX Cloudflare collector. Fixed CLI feature, result, duration and error counts are aggregated locally by day and sent separately without the ID."}</p>
<p>{zh ? "不采集提示词、代码、路径、命令参数、Goal 内容或原始错误。当前功能计数仅覆盖 CLI;命令成功不等于 Goal 完成。" : "No prompts, code, paths, arguments, Goal contents or raw errors. Feature counts currently cover CLI only; command success is not Goal completion."}</p>
<p>{zh ? "按天分别汇总所有 Host 的 quota→spend 推进周期、已绑定 Codex 任务的本地轮次时间、受管 Turn 与普通 Goal 对话的 Host 调用时间。上传固定 Host 类别及跨度/时长区间,不上传会话内容、Goal 或安装标识。三种口径重叠,不能相加;可能漏计,不代表完成、CPU 用时或计费。" : "Daily, separate span/duration buckets for all Hosts using quota→spend, local timing events from bound Codex tasks, and direct Host calls in managed Turns and regular owner Goal chat. Sends fixed Host categories, never session contents, Goal or installation IDs. The three overlapping populations cannot be added; partial observations are not completion, CPU time or billing."}</p>
{state ? <>
<label><input type="checkbox" checked={state.consent !== "disabled"} disabled={busy}
<label><input type="checkbox" checked={state.consent === "enabled" || (state.consent === "default" && !state.notice_required)} disabled={busy}
onChange={event => void update(event.target.checked)} /> {zh ? "允许基础使用统计(整台机器)" : "Allow basic usage statistics (this machine)"}</label>
<p role="status">{state.sending ? (zh ? "已允许发送" : "Sending allowed")
: state.notice_required && state.consent !== "disabled" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending")
Expand Down
26 changes: 22 additions & 4 deletions docs/reference/usage-ping.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,29 @@ Additional payload fields and invalid enum combinations are rejected.

## Disclosure and precedence

The first interactive CLI command prints the recipient, fields, purpose and
Interactive CLI, unattended scripts/agents and the App use the same
**first disclosure → automatic activation → subsequent measurement** policy.
The first ordinary CLI command prints the recipient, fields, purpose and
both disable mechanisms to stderr, records the disclosure, and sends nothing.
Later commands may measure/send. A fresh unattended installation does not
silently opt itself in: use the visible App setting or explicit CLI enable.
JSON stdout is unaffected. Previously enabled v0 clients keep their random ID
This also applies to captured stderr in scripts and Agent tool calls; JSON
stdout is unaffected. Discarded stderr (the null device) or a failed write
cannot acknowledge a notice. Background `chat`/`serve-status` services defer
first disclosure to the App instead of treating a service log as the App UI.

On first opening the live App, a visible notice explains the collection and
recipient, with **Turn off**, **Details** and **Dismiss** controls. After the
notice paints in a visible tab, the App records the same acknowledgment as CLI;
no enable click or visit to settings is needed. A hidden tab, read-only shared
view, unavailable settings service or status read alone does not acknowledge it.
Acknowledgment itself never sends a measurement. Later supported activity may
measure/send; individual App clicks remain outside the collection scope.
Settings → Capability Center keeps the shared switch and payload previews.

This changes the previous unattended CLI and App defaults: unattended CLI no
longer requires explicit enable, and the App no longer requires a first-use
enable button under the default `opt_out` policy. Environment overrides and
`consent_required` retain their precedence.
Previously enabled v0 clients keep their random ID
but must see the expanded-scope disclosure; previously disabled clients stay off.

An explicit stored disable blocks all channels. The following environment
Expand Down
17 changes: 14 additions & 3 deletions docs/reference/usage-ping.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,20 @@ ID 随机生成,不绑定账号、不从硬件派生,但能跨天关联,

## 告知、设置与升级

首次交互式 CLI 命令向 stderr 显示接收方、字段、用途和关闭方法,然后记录告知;
这一轮不计数、不发送。后续命令才有资格采集。全新无人值守安装不会静默启用,
需要所有者在 App 设置或 CLI 中明确开启。JSON stdout 保持不变。
交互式 CLI、后台脚本/Agent 和 App 统一采用 **首次告知 → 自动开启 → 后续采集**。
首次普通 CLI 命令向 stderr 显示接收方、字段、用途和关闭方法,然后记录告知;
这一轮不计数、不发送。脚本和 Agent 工具调用捕获的 stderr 也适用,JSON stdout
保持不变。stderr 指向空设备或输出失败时不记录告知。后台 `chat`/`serve-status`
服务把首次告知交给 App,不以服务日志代替 App 界面。

首次打开可操作的 App 时,页面显示统计范围、接收方及“关闭统计”“了解详情”
和“收起”入口。告知在可见标签页中呈现后自动记录,不需要先进入设置或点击
启用。隐藏标签页、只读分享页面、设置服务不可用或单纯查询状态均不记录告知。
记录告知本身不发送统计,后续受支持活动才有资格采集;App 逐次点击仍不在采集范围内。
设置 → 能力中心保留共用开关和待发送数据预览。

这是对旧默认行为的调整:默认 `opt_out` 策略下,后台 CLI 不再要求明确开启,
App 不再要求首次点击启用。环境变量覆盖和 `consent_required` 的优先级不变。
旧版明确关闭的选择继续生效;旧版已经开启的保留随机 ID,但扩大范围前重新告知。

以下任一设置都会压过“已开启”,同时关闭所有通道:
Expand Down
9 changes: 6 additions & 3 deletions loopx/chat_usage_statistics_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,19 @@ def _usage_statistics_status(self) -> None:
def _usage_statistics_update(self) -> None:
try:
body = self._read_json()
if set(body) == {"notice"} and isinstance(body["notice"], dict):
self._usage_statistics_request("acknowledge", notice=body["notice"])
return
if set(body) != {"enabled"} or not isinstance(body["enabled"], bool):
raise ValueError("enabled must be the only field and a boolean")
raise ValueError("provide either a boolean enabled or the displayed notice")
except (TypeError, ValueError) as exc:
self._send_error(str(exc), status=400, error_code="invalid_usage_settings")
return
self._usage_statistics_request("enable" if body["enabled"] else "disable")

def _usage_statistics_request(self, action: str) -> None:
def _usage_statistics_request(self, action: str, **fields: Any) -> None:
try:
projection = control(action)
projection = control(action, **fields)
except (OSError, ValueError, RuntimeError, TimeoutError, subprocess.TimeoutExpired):
self._send_error("Usage settings unavailable; use loopx usage-ping status in the terminal.",
status=503, error_code="usage_settings_unavailable")
Expand Down
12 changes: 10 additions & 2 deletions loopx/control_plane/runtime/usage_statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ function notice(ctx: Context): Notice {
function sameNotice(state: State, ctx: Context): boolean {
return JSON.stringify(state.notice) === JSON.stringify(notice(ctx));
}
function automaticNoticeRequired(state: State, ctx: Context): boolean {
// New installations and expanded disclosures may use notice-before-default-on.
// A changed recipient or policy still needs the owner's explicit choice.
return blockedBy(state, ctx) === "notice_required"
&& (!state.notice || (state.notice.version !== NOTICE_VERSION
&& state.notice.endpoint === endpoint(ctx.env) && state.notice.policy === notice(ctx).policy));
}
export function blockedBy(state: State, ctx: Context): string | null {
const env = ctx.env;
if (["0", "false", "no", "off"].includes((env.LOOPX_USAGE_PING ?? "").trim().toLowerCase())) return "LOOPX_USAGE_PING";
Expand Down Expand Up @@ -89,7 +96,8 @@ export async function inspect(path: string, ctx: Context) {
const blocked = blockedBy(state, ctx);
return { schema: "loopx_usage_ping_status_v1", consent: state.consent, sending: blocked === null,
blocked_by: blocked, endpoint: endpoint(ctx.env) || null, policy: notice(ctx).policy,
notice: notice(ctx), notice_required: !sameNotice(state, ctx), last_sent_day: state.last_sent_day ?? null,
notice: notice(ctx), notice_required: !sameNotice(state, ctx),
automatic_notice_required: automaticNoticeRequired(state, ctx), last_sent_day: state.last_sent_day ?? null,
next_payload: state.consent === "disabled" ? null : ping(state, ctx),
aggregate_preview: state.consent === "disabled" || !state.counters?.length ? null : { schema: AGGREGATE_SCHEMA, counters: state.counters },
goal_preview: state.consent === "disabled" ? null : await goalPreview(path + ".goals", state.generation).catch(() => null),
Expand All @@ -107,7 +115,7 @@ export async function configure(path: string, ctx: Context, action: "enable" | "
return;
}
if (action === "acknowledge" && JSON.stringify(expectedNotice) !== JSON.stringify(notice(ctx))) throw new Error("usage_notice_changed");
if (action === "acknowledge" && state.consent === "disabled") return;
if (action === "acknowledge" && !automaticNoticeRequired(state, ctx)) return;
if (action === "enable") state.consent = "enabled";
if (state.notice && !sameNotice(state, ctx)) {
state.counters = [];
Expand Down
Loading
Loading