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
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,22 @@ const written = spawnSync(python, ["-c", [
].join("\n"), codex], { cwd: repoRoot, encoding: "utf8" });
assert.equal(written.status, 0, written.stderr);

const server = spawn(python, ["-c", "import sys; from loopx.entrypoint import main; sys.exit(main())",
"chat", "--no-open", "--port", String(port), "--codex-bin", codex, "--scan-root", workspace, "--global-registry"],
{ cwd: root, env: { ...process.env, HOME: join(root, "home"), PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] });
let serverError = "";
for (const stream of [server.stdout, server.stderr]) stream.on("data", (chunk) => { serverError += String(chunk); });
function startServer(grant) {
const child = spawn(python, ["-c", "import sys; from loopx.entrypoint import main; sys.exit(main())",
"chat", "--no-open", "--port", String(port), "--codex-bin", codex, "--scan-root", workspace,
"--global-registry", "--project-workspace-grant", grant],
{ cwd: root, env: { ...process.env, HOME: join(root, "home"), PYTHONPATH: repoRoot, LOOPX_USAGE_PING: "0" }, stdio: ["ignore", "pipe", "pipe"] });
for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { serverError += String(chunk); });
return child;
}
let server = startServer("workspace_read");
async function stopServer() {
if (server.exitCode !== null) return;
const exited = new Promise((resolveExit) => server.once("exit", resolveExit));
server.kill();
await exited;
}
// The first-run usage notice is unrelated to this journey; keep evidence focused.
async function capture(target, name) {
if (!screenshots) return;
Expand Down Expand Up @@ -61,6 +72,16 @@ try {
await page.getByLabel("发送消息").fill(question);
await page.keyboard.press("Enter");
await page.getByText("Runtime response.").first().waitFor({ timeout: 15_000 });
// Upgrade a persisted read-only workspace through the same ordinary Scope entry.
await stopServer();
server = startServer("workspace_write");
await waitForHttp(url).catch((error) => { throw new Error(`${error.message}\n${serverError}`); });
await page.reload({ waitUntil: "networkidle" });
await page.getByText("工作区对话 · 读写").waitFor({ timeout: 15_000 });
await page.getByText(question).first().waitFor({ timeout: 15_000 });
await page.getByLabel("发送消息").fill("继续整理素材");
await page.keyboard.press("Enter");
await page.getByText("Runtime response.").nth(1).waitFor({ timeout: 15_000 });
await capture(page, "ordinary-workspace-conversation.png");

const projectRequests = sessionRequests.filter((body) => body.context_kind === "project");
Expand Down Expand Up @@ -98,6 +119,6 @@ try {
console.log("workspace scope browser smoke ok");
} finally {
await browser?.close();
server.kill();
await stopServer();
await rm(root, { force: true, recursive: true });
}
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/data/chat-model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { GoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js";
export type LoopXModeSettings = { agent_id: string; token_budget: number };
/** A host-granted workspace an ordinary conversation may be scoped to. */
export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" };
export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" | "workspace_write" };

export type ChatTodo = {
todo_id: string | null;
Expand Down
6 changes: 3 additions & 3 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2385,7 +2385,7 @@ const privateAgentTargetSchema = privateAgentSessionSchema.extend({target_ref: z
const privateConversationSchema = z.object({
binding_id: z.string(), app_ref: z.string(), context_kind: z.enum(["project", "steward"]),
project_ref: z.string(), project_title: z.string(), context_available: z.boolean(), executor_endpoint_id: z.string(),
grant: z.enum(["workspace_read", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(),
grant: z.enum(["workspace_read", "workspace_write", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(),
pending_count: z.number().int(), recovery_count: z.number().int(),
agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]),
});
Expand All @@ -2395,10 +2395,10 @@ export type PrivateConversation = z.infer<typeof privateConversationSchema>;
export async function fetchPrivateConversations() {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations"));
}
export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project") {
export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write") {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor, context_kind: contextKind}),
body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor, context_kind: contextKind, project_grant: projectGrant}),
}));
}
export async function disconnectPrivateConversation(bindingId: string, revision: number) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,7 @@ const en = {
"header.scopeWorkspacesUnavailable": "Workspaces could not be read",
"workspace.conversation": "Workspace conversation · {grant}",
"workspace.grantRead": "read-only",
"workspace.grantWrite": "read and write",
"workspace.grantRevoked": "no longer authorized",
"workspace.unknownTitle": "Unavailable workspace",
"workspace.unavailable": "This host no longer authorizes this workspace. The history is kept; new messages will be rejected until it is authorized again.",
Expand Down Expand Up @@ -2445,6 +2446,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"header.scopeWorkspacesUnavailable": "暂时无法读取工作区",
"workspace.conversation": "工作区对话 · {grant}",
"workspace.grantRead": "只读",
"workspace.grantWrite": "读写",
"workspace.grantRevoked": "已不再授权",
"workspace.unknownTitle": "不可用的工作区",
"workspace.unavailable": "此宿主已不再授权这个工作区。历史记录会保留;重新授权前,新消息会被拒绝。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1880,7 +1880,7 @@ export function PersonalWorkspacePage({
],
value: selectedWorkspaceRef ?? stewardScopeValue,
} : null}
workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : "workspace.grantRead") : null}
workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : selectedWorkspaceProject?.grant === "workspace_write" ? "workspace.grantWrite" : "workspace.grantRead") : null}
managerChatOpen={managerChatOpen}
managerChannelBinding={managerChannelBinding}
managerRuntime={managerRuntime}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function PrivateConversationPanel() {
const [project, setProject] = useState("");
const [executor, setExecutor] = useState("");
const [role, setRole] = useState<"project" | "steward">("project");
const [projectGrant, setProjectGrant] = useState<"workspace_read" | "workspace_write">("workspace_write");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);

Expand All @@ -42,6 +43,9 @@ export function PrivateConversationPanel() {
return () => {active = false; clearInterval(timer);};
}, []);

const effectiveProjectGrant = executor === "codex" && projects.find(item => item.project_ref === project)?.grant === "workspace_write"
? projectGrant : "workspace_read";

function listenerLabel(state: string) {
const labels: Record<string, [string, string]> = {starting: ["启动中", "Starting"], listening: ["实时连接就绪", "Live connection ready"],
standby: ["等待已有监听服务", "Waiting for the existing listener"], retrying: ["重连中", "Reconnecting"],
Expand All @@ -54,12 +58,20 @@ export function PrivateConversationPanel() {
try {await operation(); await refresh();} catch (error) {setError(String(error));}
finally {setBusy(false);}
}
function selectApp(appRef: string) {
setApp(appRef);
const saved = rows.find(row => row.app_ref === appRef);
if (saved) {
setProject(saved.project_ref); setExecutor(saved.executor_endpoint_id); setRole(saved.context_kind);
setProjectGrant(saved.grant === "workspace_write" ? "workspace_write" : "workspace_read");
} else setProjectGrant("workspace_write");
}
return <section className="personal-detail-card personal-private-conversation" aria-label={zh ? "本人飞书私聊" : "Owner private Chat"}>
<h3>{zh ? "本人私聊 · 项目助手与管家" : "Owner private Chat · Project assistant and steward"}</h3>
<p>{zh ? "每个 App 单独核验本人。项目助手只读讨论工作区,不创建隐藏 Goal。管家从空 portfolio 开始,只管理在此入口明确确认的新委托。" : "Verify the owner independently for each App. Project Chat discusses the workspace without hidden Goals. A steward starts with an empty portfolio and manages only new commissions explicitly confirmed here."}</p>
<p>{zh ? "每个 App 单独核验本人。项目助手默认支持工作区读写,按项目规则与 skills 执行编辑;可选只读。普通聊天不创建隐藏 Goal。管家从空 portfolio 开始,只管理在此入口明确确认的新委托。" : "Verify the owner independently for each App. Project Chat defaults to workspace writes under project rules and skills; read-only remains available without hidden Goals. A steward starts with an empty portfolio and manages only new commissions explicitly confirmed here."}</p>
{rows.map(row => <article key={row.binding_id}>
<strong>{row.app_ref} · {row.context_available ? row.project_title : (zh ? "工作区不可用" : "Workspace unavailable")}</strong>
<p>{row.context_kind === "steward" ? (zh ? `LoopX 管家 · ${row.goal_count === 0 ? "暂无已授权的新委托;没有继承旧目标。" : `${row.goal_count} 个已确认的新委托`}` : `LoopX steward · ${row.goal_count} new confirmed commissions; no inherited Goals.`) : (zh ? "普通项目助手 · 只读对话" : "Project assistant · Read-only Chat")}</p>
<p>{row.context_kind === "steward" ? (zh ? `LoopX 管家 · ${row.goal_count === 0 ? "暂无已授权的新委托;没有继承旧目标。" : `${row.goal_count} 个已确认的新委托`}` : `LoopX steward · ${row.goal_count} new confirmed commissions; no inherited Goals.`) : (row.grant === "workspace_write" ? (zh ? "普通项目助手 · 已授权工作区读写" : "Project assistant · Workspace writes authorized") : (zh ? "普通项目助手 · 只读对话" : "Project assistant · Read-only Chat"))}</p>
<p>{row.executor_endpoint_id} · {zh ? "监听状态" : "Listener"}: {listenerLabel(row.listener_status)}</p>
<p>{zh ? `待处理或回复:${row.pending_count}` : `Pending execution or reply: ${row.pending_count}`}</p>
{row.recovery_count > 0 ? <p role="status">{zh ? "存在尚未确认的发送回执。服务会读取原回执恢复;不要重新发送同一任务。检查 App 登录、权限和原会话后刷新状态。" : "A send receipt is unconfirmed. The service reads the original receipt to recover; avoid resending the same task. Check this App login, permissions and original Session, then refresh status."}</p> : null}
Expand All @@ -68,7 +80,7 @@ export function PrivateConversationPanel() {
<button disabled={busy} onClick={() => void act(() => disconnectPrivateConversation(row.binding_id, revision))} type="button">{zh ? "解绑" : "Disconnect"}</button>
</article>)}
{rows.length === 0 ? <p>{zh ? "尚未连接本人私聊。" : "No owner private Chat connected."}</p> : null}
<label>App<select aria-label={zh ? "私聊 App" : "Private Chat App"} value={app} disabled={busy} onChange={event => setApp(event.target.value)}>
<label>App<select aria-label={zh ? "私聊 App" : "Private Chat App"} value={app} disabled={busy} onChange={event => selectApp(event.target.value)}>
<option value="">{zh ? "选择已验证 App" : "Select a verified App"}</option>
{apps.map(app => <option key={app.app_ref} value={app.app_ref}>{app.label} · {app.app_ref}</option>)}
</select></label>
Expand All @@ -82,7 +94,12 @@ export function PrivateConversationPanel() {
<label>{zh ? "执行器" : "Executor"}<select aria-label={zh ? "私聊执行器" : "Private Chat executor"} value={executor} disabled={busy} onChange={event => setExecutor(event.target.value)}>
{executors.map(executor => <option key={executor} value={executor}>{executor}</option>)}
</select></label>
<div className="personal-detail-actions"><button disabled={busy || !app || !project || !executor} onClick={() => void act(() => connectPrivateConversation(app, project, executor, role))} type="button">
{role === "project" ? <label>{zh ? "工作区权限" : "Workspace access"}<select aria-label={zh ? "私聊工作区权限" : "Private Chat workspace access"} value={effectiveProjectGrant} disabled={busy || executor !== "codex"} onChange={event => setProjectGrant(event.target.value as "workspace_read" | "workspace_write")}>
<option value="workspace_read">{zh ? "只读" : "Read-only"}</option>
<option value="workspace_write" disabled={projects.find(item => item.project_ref === project)?.grant === "workspace_read"}>{zh ? "工作区读写(默认)" : "Workspace writes (default)"}</option>
</select></label> : null}
{role === "project" && effectiveProjectGrant === "workspace_write" ? <p role="status">{zh ? "此 App 可按你的指令编辑选定工作区;不提高已直连 Agent 的原宿主权限。更改权限会建立新绑定和新会话,旧会话不会自动提升权限,已有 Agent 直连需重新授权。" : "This App may edit the selected workspace on your instruction; attached Agents retain their original host permissions. Changing access creates a new binding and Session. Existing Chat does not gain access and Agent targets require new authorization."}</p> : null}
<div className="personal-detail-actions"><button disabled={busy || !app || !project || !executor} onClick={() => void act(() => connectPrivateConversation(app, project, executor, role, role === "project" ? effectiveProjectGrant : "workspace_read"))} type="button">
{busy ? (zh ? "正在核验" : "Verifying") : (zh ? "连接本人私聊" : "Connect owner private Chat")}</button>
<button disabled={busy} onClick={() => void act(refresh)} type="button">{zh ? "刷新状态" : "Refresh status"}</button></div>
<p>{zh ? "从手机发送文字开始;后续消息进入原会话队列。/status 查看工作区、角色与持久排队状态,/help 查看用法与解绑入口,/stop 停止当前聊天执行,/new 开启新会话。图片、文件会明确提示暂不支持。" : "Send text from your phone to begin; follow-ups queue in the same Session. /status shows the workspace, role and durable queue, /help explains commands and where to unbind, /stop stops the current Chat Turn, /new starts a new conversation. Images and files receive an explicit unsupported response."}</p>
Expand Down
45 changes: 40 additions & 5 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ the TypeScript owner decides context identity and scope. Native Codex resume ret
the original upstream thread and workspace. HTTP/protocol fixtures qualify that
continuity and denial behavior; they do not establish real model adoption.

The App grant is workspace reading for the local owner. The App scope alone does
not qualify Lark private-message admission, installed or mobile journeys, or
authorize edits. A revoked grant keeps the history readable and blocks new
The local App grant covers the selected owner workspace under its current host
permissions. Scope selection alone does not qualify Lark private-message
admission, installed or mobile journeys. A revoked grant keeps history readable and blocks new
messages until the host grants it again. The independent Lark checkpoint below
qualifies its source implementation separately.

Expand All @@ -51,8 +51,7 @@ shared request owner, and a replay retains the original Session/Turn target.
A lost admission correlation cannot move a request to a newer Session. Admission
feedback and final delivery use separate durable provider intents; an ambiguous
write is read back without blind resend. Unsupported media receives an explicit
notice. This grant remains workspace reading, without a Goal, portfolio or peer
execution authority.
notice. The workspace grant conveys no Goal, portfolio or peer execution authority.

The packaged settings journey, source revocation/recovery, duplicate events,
native queue/stop and two-App isolation have synthetic-provider regression and
Expand All @@ -74,6 +73,42 @@ Synthetic product previews: [desktop](../../assets/personal-workspace/private-pr
[narrow](../../assets/personal-workspace/private-project-conversations-narrow.png),
[revoked workspace](../../assets/personal-workspace/private-project-workspace-revoked.png).

## Ordinary workspace writes: default and revocation checkpoint

Ordinary project Chat defaults to `workspace_write` for host-declared roots. The
Core context owner derives the actual Codex `workspace-write` sandbox on start
and exact-thread resume; this is conversational work, without Task/Goal mode or
manager permissions. The project prompt follows workspace `AGENTS.md` and skills,
permits requested bounded edits, and keeps durable operations with their existing
owners. A write grant does not activate Material Lifecycle or certify a project
adapter's intake/ranking workflow.

`loopx chat --project-workspace-grant workspace_read` restricts the host to
read-only, including Lark bindings. Settings → Lark defaults a Codex project App
to workspace writes within that host grant and exposes an explicit read-only
choice. Other executors remain read-only until their host policy is qualified.
The readback, `/status` and `/help` show the effective grant. Selecting an existing
App restores its persisted setting. Changing a binding's grant requires a new
identity and Session, invalidates the old Session for new work, and requires new
Agent target grants; it cannot silently elevate an attached host or another App.
Host grant removal or downgrade is rechecked before admission and resume. Ordinary
local Scope reuses only the same typed project context; a host grant change opens
a new Session while retaining old history and rejecting new work on the old
Session.

Typed Core/HTTP/native-host regressions qualify default writes, explicit read-only,
workspace identity, independent App grants, old-Session rejection and exact-thread
resume. Earlier source evidence records a Codex canary editing and reading back a
synthetic note while preserving prior text and creating no Goal. This historical
host/filesystem evidence is separate from current synthetic-protocol regression
checks and does not qualify live Lark, material intake or a release.
Committed same-claim replay is separate from new admission: after a lost response
and target revocation, the original host can recover its committed receipt; new
claims and external result publication remain denied. Current validation uses real
Core HTTP, file storage and the claim broker with synthetic Codex/provider
transport; it does not rerun a live-model edit or phone journey. Installed/Lark
journeys and broader IM interactions remain open.

## Bound steward private Chat: explicit new commissions

Settings → Lark can now select a steward role independently of ordinary project
Expand Down
Loading
Loading