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
5 changes: 5 additions & 0 deletions .changeset/unify-session-team-engineering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nori-code": minor
---

Make department members the same Sessions as conversation-map cards, so hire, chat, Discuss, and `/map` share one forest. Hire with TeamCreate or the map; open a card to work in that session. In the terminal, `/team` and `/map` keep the live department snapshot, and Ctrl-Y shows Chat on the lead session.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Nori is a coding-agent workspace forked from [Kimi Code CLI](https://github.com/

- Any agent may hire members with `TeamCreate` and chair its own department, bounded by `team.maxDepth` (default `2`, maximum `5`).
- A Discuss round is one department: a parent plus its direct members. A node never chairs and participates at the same time.
- Hiring uses the same path as the conversation map: create a **real child session**, mount it with `parent_session_id`, and show it as a session card. The runtime also **dual-writes** a team agent so Discuss/Assign still address this department by agent id. That dual-write is an implementation seam, not a finished unified identity (see [Honest gaps](#3-honest-gaps)).
- Hiring uses the same path as the conversation map: create a **real child session**, mount it with `parent_session_id`, and show it as a session card. Work, tools, sibling chat, and identity live on that child session. Discuss/Assign address the session id (or display name).
- `TeamDismiss` removes a member and **deletes** that child session. Unmount on the map is a user action: detach without deleting. A session has one parent; part-time / second-parent hire is not supported.

### Discuss, then Code
Expand Down Expand Up @@ -98,7 +98,7 @@ The project owner described LSP and Git as a rough shell (「毛坯房」). Afte
### Other gaps verified in this repo

- **TUI test debt** (from the changelog): about 66 failing tests across 25 files in `apps/nori-code`. They still assert the pre-rename `kimi-code` home directory, user-agent, and command names, or slash commands the registry has not exposed for a long time. The count moved from 68 to 66 only because SubAgent’s own tests were deleted with the feature.
- **Dual-write hire:** The product path is “empty child session + mount + a team agent on the parent.” Discuss/Assign speak agent ids; the map speaks session ids. After a crash, an idempotent sync has to reattach both sides. Known seam, not a unified identity model.
- **Existing dual-write sessions migrate on load:** Older hires that still have a parent-session team agent are bound to (or materialized as) a child session, then the shadow is dropped. New hires never create that shadow.
- **Kimi naming leftovers:** The TUI coordinator is still `KimiTUI`; build macros are `__KIMI_CODE_*`; native cache paths can still land under `kimi-code`; the docs theme and many VitePress pages still carry upstream branding and SubAgent copy. `pnpm check:brand` catches public brand drift; it does not mean every internal identifier is gone.
- **Map peer/service edges live in localStorage:** Parent edges are server `parent_session_id`. Peer edges, service edges, annotations, and pinned positions live in `nori-session-map-doc`. Clearing site data drops them. Server-side graph storage has not landed (see `docs/adr/pre.1-session-node-graph.md`).
- **`nori.yaml` is not a DAG scheduler:** The file still contains `phases:`, step lists, and leftover SubAgent rules. What the runtime actually uses is rule-prompt injection plus review / memory / bug-hunt **gates** (score activity, inject instructions). There is no `depends_on` node runner. Older README text that sold this YAML as policy-as-code DAG orchestration overclaimed.
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Nori 是一个从 [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code)(MIT

- 每个 Agent 可以用 `TeamCreate` 雇佣自己的成员,并主持自己的部门。深度受 `team.maxDepth` 约束(默认 `2`,上限 `5`)。
- 一次 Discuss 的范围是**一个部门**:父节点 + 它的直接成员。节点不会同时当主席又当发言人。
- 雇佣走的是和会话地图同一条路径:创建一个**真实子会话**,用 `parent_session_id` 挂到你下面,地图上就是一张会话卡片。同时会 **dual-write** 一个团队 Agent,好让 Discuss / Assign 仍按本部门的 agent id 寻址。这是实现上的双写,不是已经磨平的统一身份(见 [缺口](#3-诚实的缺口))。
- 雇佣走的是和会话地图同一条路径:创建一个**真实子会话**,用 `parent_session_id` 挂到你下面,地图上就是一张会话卡片。工作、工具、兄弟交流和身份都住在这个子会话上。Discuss / Assign 按 session id(或展示名)寻址。
- `TeamDismiss` 移除成员并**删除**对应子会话。地图上的 Unmount 是用户操作:只拆挂载,不删会话。一个会话目前只能有一个父节点。

### 先 Discuss,再 Code
Expand Down Expand Up @@ -98,7 +98,7 @@ Codex 和 Claude Code 仍然提供**打磨过的一次性 subagent fan-out**(
### 代码里核对过的其它缺口

- **TUI 测试债**(changelog 原文):`apps/nori-code` 里约 66 个测试、25 个文件失败。一部分还在断言改名之前的 `kimi-code` 家目录、UA、命令名;一部分在断言注册表很久没再暴露的斜杠命令。数量从 68 降到 66,只是因为 SubAgent 自己的测试随功能一起删了。
- **雇佣 dual-write**:产品路径是「空子会话 + 挂载 + 父会话里再挂一个 team agent」。Discuss / Assign 仍按 agent id 说话;地图按 session id 说话。crash 之后要靠幂等 sync 把两边对齐。这是已知接缝,不是已经统一的身份模型。
- **已有 dual-write 会话会在加载时迁移:** 父会话里还挂着 team agent 的旧雇佣,会绑到(或物化成)子会话,再拆掉影子。新雇佣不再创建影子。
- **Kimi 命名残留**:TUI 协调器仍叫 `KimiTUI`;构建宏是 `__KIMI_CODE_*`;原生缓存目录仍能落到 `kimi-code`;文档站组件和不少 VitePress 页面还带着上游品牌与 SubAgent 说法。`pnpm check:brand` 管的是对外品牌漂移,不是一次清完所有内部标识。
- **Map 的 peer / service 边只在 localStorage**:父边以服务端 `parent_session_id` 为准。对等边、服务边、标注、钉住的位置写在 `nori-session-map-doc` 里,换浏览器或清站点数据就会丢。服务端图存储还没落地(见 `docs/adr/pre.1-session-node-graph.md`)。
- **`nori.yaml` 不是 DAG 调度器**:文件里有 `phases:`、步骤、甚至旧的 SubAgent 规则,但运行时真正读的是规则 prompt 注入,以及 review / memory / bug-hunt **闸门**(复杂度打分后往上下文里塞指令)。没有一个按 `depends_on` 跑节点的编排引擎。旧 README 把这份 YAML 写成「策略即代码的 DAG」,那是超售。
Expand Down
107 changes: 17 additions & 90 deletions apps/nori-code/src/tui/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import type { SessionGraphSummary, SessionSummary } from '@nori-code/sdk';
import { SessionMapBrowserComponent } from '../components/dialogs/session-map-browser';
import { TextInputDialogComponent } from '../components/dialogs/text-input-dialog';
import { formatErrorMessage } from '../utils/event-payload';
import { parentSessionIdOf } from '../utils/session-map-tree';
import { teamAgentsFromSessionMetadata, type TeamAgentSnapshot } from '../utils/team-tree';
import { parentSessionIdOf, sessionMapLabel } from '../utils/session-map-tree';
import { buildDepartmentSnapshot } from '../utils/team-tree';
import type { SlashCommandHost } from './dispatch';

type MappedTeamAgent = TeamAgentSnapshot & { readonly hostSessionId: string };

export async function handleMapCommand(host: SlashCommandHost): Promise<void> {
let graph: SessionGraphSummary;
try {
Expand All @@ -18,57 +16,26 @@ export async function handleMapCommand(host: SlashCommandHost): Promise<void> {
return;
}

let mappedTeamAgents: MappedTeamAgent[] = [];
const render = (): void => {
host.mountEditorReplacement(
new SessionMapBrowserComponent({
nodes: graph.nodes,
edges: graph.edges,
currentSessionId: host.session?.id,
onOpen: (session) => {
const parentId = parentSessionIdOf(session.metadata as Record<string, unknown> | undefined);
if (parentId !== undefined) {
void (async () => {
let member = mappedTeamAgents.find(
(agent) =>
agent.kind === 'team'
&& agent.mountedSessionId === session.id
&& agent.hostSessionId === parentId,
);
if (member === undefined) {
mappedTeamAgents = await refreshTeamAgents(host, graph.nodes);
member = mappedTeamAgents.find(
(agent) =>
agent.kind === 'team'
&& agent.mountedSessionId === session.id
&& agent.hostSessionId === parentId,
);
}
if (member === undefined) {
host.showError(`Mounted session "${session.id}" has no owning Team agent.`);
render();
return;
}
await openMountedTeamAgent(host, member, render);
})().catch((error) => {
host.showError(formatErrorMessage(error));
render();
});
return;
}
void openSession(host, session.id, render);
},
onMount: (child, parent) => {
void applyMount(host, child, parent, async () => {
graph = await host.harness.getSessionGraph({ workDir: host.state.appState.workDir });
mappedTeamAgents = await refreshTeamAgents(host, graph.nodes);
refreshTeamAgents(host, graph);
render();
}, render);
},
onUnmount: (session) => {
void applyUnmount(host, session, async () => {
graph = await host.harness.getSessionGraph({ workDir: host.state.appState.workDir });
mappedTeamAgents = await refreshTeamAgents(host, graph.nodes);
refreshTeamAgents(host, graph);
render();
}, render);
},
Expand All @@ -79,65 +46,25 @@ export async function handleMapCommand(host: SlashCommandHost): Promise<void> {
);
};

mappedTeamAgents = await refreshTeamAgents(host, graph.nodes);
refreshTeamAgents(host, graph);
render();
}

async function refreshTeamAgents(
host: SlashCommandHost,
nodes: readonly SessionSummary[],
): Promise<MappedTeamAgent[]> {
const mapped: MappedTeamAgent[] = [];
for (const node of nodes) {
try {
const metadata = await host.harness.getSessionMetadata(node.id);
mapped.push(
...teamAgentsFromSessionMetadata(metadata).map((agent) => ({
...agent,
hostSessionId: node.id,
})),
);
} catch {
// The graph update is still useful when one session's metadata is unavailable.
}
}
function refreshTeamAgents(host: SlashCommandHost, graph: SessionGraphSummary): void {
const currentSessionId = host.session?.id;
if (currentSessionId === undefined) return;
const hostNode = graph.nodes.find((node) => node.id === currentSessionId);
host.setAppState({
teamAgents: mapped.filter((agent) => agent.hostSessionId === currentSessionId),
teamAgents: buildDepartmentSnapshot({
hostSessionId: currentSessionId,
hostTitle: hostNode === undefined
? (host.state.appState.sessionTitle ?? 'Main')
: sessionMapLabel(hostNode),
graph,
metadata: host.session?.getResumeState()?.sessionMetadata,
live: host.state.appState.teamAgents,
}),
});
return mapped;
}

async function openMountedTeamAgent(
host: SlashCommandHost,
member: MappedTeamAgent,
reopen: () => void,
): Promise<void> {
try {
host.restoreEditor();
let session = host.session;
if (session?.id !== member.hostSessionId) {
session = await host.harness.resumeSession({ id: member.hostSessionId });
await host.switchToSession(session, `Opened team session (${session.id}).`);
}
if (session === undefined) {
throw new Error(`Team member host session "${member.hostSessionId}" is unavailable.`);
}
const metadata = await session.getSessionMetadata();
const currentMember = teamAgentsFromSessionMetadata(metadata).find(
(agent) =>
agent.kind === 'team'
&& agent.agentId === member.agentId
&& agent.mountedSessionId === member.mountedSessionId,
);
if (currentMember === undefined) {
throw new Error(`Team member "${member.name}" is no longer available.`);
}
await host.teamViewController.open(currentMember);
} catch (error) {
host.showError(formatErrorMessage(error));
reopen();
}
}

async function openSession(
Expand Down
54 changes: 52 additions & 2 deletions apps/nori-code/src/tui/commands/team.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { TeamBrowserComponent } from '../components/dialogs/team-browser';
import { TeamMemberDetailComponent } from '../components/dialogs/team-member-detail';
import { currentViewingAgentId, type TeamAgentSnapshot } from '../utils/team-tree';
import { currentViewingAgentId, buildDepartmentSnapshot, teamMemberSessionId, type TeamAgentSnapshot } from '../utils/team-tree';
import { sessionMapLabel } from '../utils/session-map-tree';
import { formatErrorMessage } from '../utils/event-payload';
import type { TranscriptEntry } from '../types';
import { showTeamSettingsPicker } from './config';
import type { SlashCommandHost } from './dispatch';
Expand All @@ -11,9 +13,32 @@ export async function handleTeamCommand(host: SlashCommandHost, args: string): P
await showTeamSettingsPicker(host);
return;
}
await refreshDepartmentFromGraph(host);
showTeamBrowser(host);
}

async function refreshDepartmentFromGraph(host: SlashCommandHost): Promise<void> {
const sessionId = host.session?.id;
if (sessionId === undefined) return;
try {
const graph = await host.harness.getSessionGraph({ workDir: host.state.appState.workDir });
const hostNode = graph.nodes.find((node) => node.id === sessionId);
host.setAppState({
teamAgents: buildDepartmentSnapshot({
hostSessionId: sessionId,
hostTitle: hostNode === undefined
? (host.state.appState.sessionTitle ?? 'Main')
: sessionMapLabel(hostNode),
graph,
metadata: host.session?.getResumeState()?.sessionMetadata,
live: host.state.appState.teamAgents,
}),
});
} catch (error) {
host.showError(formatErrorMessage(error));
}
}

function showTeamBrowser(host: SlashCommandHost): void {
host.mountEditorReplacement(
new TeamBrowserComponent({
Expand All @@ -22,7 +47,32 @@ function showTeamBrowser(host: SlashCommandHost): void {
discussMode: host.state.appState.discussMode,
currentAgentId: currentViewingAgentId(host.state.appState.viewingAgentId),
onSelect: (agent) => {
void host.teamViewController.open(agent);
if (agent.kind === 'main') {
host.restoreEditor();
return;
}
if (agent.kind === 'discussion') {
host.restoreEditor();
host.teamViewController.reveal();
host.showStatus(host.state.appState.discussMode ? 'Opened Discuss' : 'Opened Chat');
return;
}
const sessionId = teamMemberSessionId(agent);
if (sessionId === undefined) {
host.showError('This member is no longer a session.');
showTeamBrowser(host);
return;
}
void (async () => {
try {
const session = await host.harness.resumeSession({ id: sessionId });
await host.switchToSession(session, `Opened session (${session.id}).`);
host.restoreEditor();
} catch (error) {
host.showError(String(error));
showTeamBrowser(host);
}
})();
},
onDetails: (agent) => {
showTeamMemberDetail(host, agent);
Expand Down
13 changes: 8 additions & 5 deletions apps/nori-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,14 @@ export class FooterComponent implements Component {
if (hired > 0) {
core.push(chalk.hex(colors.textMuted)(`[team ${String(hired)}]`));
}
const viewingId = state.viewingAgentId;
if (viewingId !== undefined && viewingId !== 'main') {
const viewingName =
state.teamAgents.find((agent) => agent.agentId === viewingId)?.name ?? viewingId;
core.push(chalk.hex(colors.primary).bold(viewingName));
const parentId = state.parentSessionId;
if (parentId !== undefined && parentId.length > 0) {
const mountedName = state.sessionTitle?.trim();
core.push(
chalk.hex(colors.primary).bold(
mountedName !== undefined && mountedName.length > 0 ? mountedName : 'member',
),
);
}
const speaking = teamSpeakingLabel(state.teamAgents);
if (speaking !== undefined) {
Expand Down
2 changes: 1 addition & 1 deletion apps/nori-code/src/tui/components/panes/department-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Department pane — read-only Discuss meeting track or sibling Chat.
*
* Discuss on: forced meeting UI. Discuss off: Chat. Closing the pane does not
* exit Discuss or the viewed member session.
* exit Discuss or the current session.
*/

import { Container, truncateToWidth, visibleWidth } from '@nori-code/pi-tui';
Expand Down
17 changes: 13 additions & 4 deletions apps/nori-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import {
extractTeamSpeechText,
isTeamSpeechTool,
shouldPaintDiscussUtterance,
teamAgentsFromSessionMetadata,
buildDepartmentSnapshot,
type TeamAgentSnapshot,
type TeamReportStatus,
} from '../utils/team-tree';
Expand Down Expand Up @@ -968,7 +968,12 @@ export class SessionEventHandler {
this.host.updateTerminalTitle();
}
if (event.patch?.['agents'] !== undefined) {
this.setTeamAgents(teamAgentsFromSessionMetadata(event.patch));
this.setTeamAgents(buildDepartmentSnapshot({
hostTitle: this.host.state.appState.sessionTitle ?? 'Main',
hostSessionId: this.host.state.appState.sessionId,
metadata: event.patch,
live: this.host.state.appState.teamAgents,
}));
}
}

Expand Down Expand Up @@ -1004,8 +1009,12 @@ export class SessionEventHandler {
}

seedTeamAgentsFromSession(session: Session): void {
const agents = teamAgentsFromSessionMetadata(session.getResumeState()?.sessionMetadata);
this.host.setAppState({ teamAgents: agents });
this.setTeamAgents(buildDepartmentSnapshot({
hostTitle: session.summary?.title ?? this.host.state.appState.sessionTitle ?? 'Main',
hostSessionId: session.id,
metadata: session.getResumeState()?.sessionMetadata,
live: this.host.state.appState.teamAgents,
}));
this.host.teamViewController.seedFromSession(session);
}

Expand Down
Loading
Loading