tmux-ide 2.9.0-beta.48 → 2.9.0-beta.49

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.
Files changed (34) hide show
  1. package/bin/cli.js +2055 -1907
  2. package/package.json +2 -2
  3. package/packages/contracts/src/cohesion-fixture.ts +2 -0
  4. package/packages/contracts/src/fleet-catalog.ts +2 -0
  5. package/packages/contracts/src/index.ts +2 -0
  6. package/packages/contracts/src/pane-team.ts +32 -0
  7. package/packages/daemon/dist/command-center/discovery.js +4 -1
  8. package/packages/daemon/dist/command-center/resources/application-shell.js +2 -1
  9. package/packages/daemon/dist/command-center/resources/fleet-catalog.js +2 -0
  10. package/packages/daemon/dist/lib/daemon-embed.js +7 -5
  11. package/packages/daemon/dist/pane-team.js +28 -0
  12. package/packages/daemon/dist/terminal/attachments/agent-status-probe.js +21 -1
  13. package/packages/daemon/dist/terminal/attachments/claude-team-names.js +22 -6
  14. package/packages/daemon/dist/terminal/attachments/manual-pane-team.js +30 -0
  15. package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-roster.jsx +1 -1
  16. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents.js +1 -0
  17. package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +3 -1
  18. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +3 -2
  19. package/packages/daemon/dist/tui/mirror/runtime/application-team-groups.js +22 -0
  20. package/packages/daemon/dist/tui/mirror/ui/agent-row.jsx +2 -2
  21. package/packages/daemon/src/command-center/discovery.ts +6 -1
  22. package/packages/daemon/src/command-center/resources/application-shell.ts +4 -1
  23. package/packages/daemon/src/command-center/resources/fleet-catalog.ts +2 -0
  24. package/packages/daemon/src/lib/daemon-embed.ts +7 -5
  25. package/packages/daemon/src/pane-team.ts +42 -0
  26. package/packages/daemon/src/terminal/attachments/agent-status-probe.ts +29 -1
  27. package/packages/daemon/src/terminal/attachments/claude-team-names.ts +43 -8
  28. package/packages/daemon/src/terminal/attachments/manual-pane-team.ts +31 -0
  29. package/packages/daemon/src/tui/mirror/runtime/application-home-agent-roster.tsx +1 -0
  30. package/packages/daemon/src/tui/mirror/runtime/application-home-agents.ts +3 -0
  31. package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +3 -1
  32. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +5 -1
  33. package/packages/daemon/src/tui/mirror/runtime/application-team-groups.ts +40 -0
  34. package/packages/daemon/src/tui/mirror/ui/agent-row.tsx +7 -3
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { isAbsolute } from "node:path";
3
+ import { runtimeTmuxArgs } from "./lib/runtime-namespace.ts";
4
+ import { manualPaneTeamStamp, PANE_TEAM_OPTION } from "./terminal/attachments/manual-pane-team.ts";
5
+
6
+ /** Explicit membership only. Never launches, stops, or messages an agent. */
7
+ export function assignPaneTeam(
8
+ options: {
9
+ paneId: string;
10
+ name: string | null;
11
+ socketPath?: string;
12
+ socketName?: string;
13
+ },
14
+ run: (args: string[]) => string = (args) =>
15
+ execFileSync("tmux", args, { encoding: "utf8", timeout: 5000 }),
16
+ ) {
17
+ if (!/^%\d+$/u.test(options.paneId)) throw new Error("Use an exact pane ID such as %3");
18
+ if (options.socketPath && options.socketName) throw new Error("Choose one tmux socket selector");
19
+ if (options.socketPath && !isAbsolute(options.socketPath))
20
+ throw new Error("Socket path must be absolute");
21
+ if (options.socketName && !/^[a-zA-Z0-9_.-]+$/u.test(options.socketName))
22
+ throw new Error("Invalid socket name");
23
+ const args = (command: string[]) =>
24
+ options.socketPath
25
+ ? ["-S", options.socketPath, ...command]
26
+ : options.socketName
27
+ ? ["-L", options.socketName, ...command]
28
+ : runtimeTmuxArgs(command);
29
+ const pid = Number(
30
+ run(args(["display-message", "-p", "-t", options.paneId, "#{pane_pid}"])).trim(),
31
+ );
32
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("Pane is no longer available");
33
+ const stamp = options.name === null ? null : manualPaneTeamStamp(options.name, pid);
34
+ run(
35
+ args(
36
+ stamp === null
37
+ ? ["set-option", "-p", "-u", "-t", options.paneId, PANE_TEAM_OPTION]
38
+ : ["set-option", "-p", "-t", options.paneId, PANE_TEAM_OPTION, stamp],
39
+ ),
40
+ );
41
+ return { paneId: options.paneId, team: options.name, source: stamp === null ? null : "manual" };
42
+ }
@@ -1,3 +1,6 @@
1
+ import { readManualPaneTeam } from "./manual-pane-team.ts";
2
+ import type { PaneTeamMembership } from "@tmux-ide/contracts";
3
+ import type { ClaudeTeamMembershipReader } from "./claude-team-names.ts";
1
4
  import type { ClaudeTeamNameReader } from "./claude-team-names.ts";
2
5
  import type { NativePaneIdentity } from "@tmux-ide/contracts";
3
6
  /**
@@ -44,6 +47,7 @@ import {
44
47
  /** The per-pane facts the pure projector consumes (see `ApplicationShellPanePresentationFacts`). */
45
48
  export interface AgentStatusPaneFacts {
46
49
  readonly teamMemberName?: string;
50
+ readonly team?: PaneTeamMembership;
47
51
  /** Stable manifest id resolved from the pane hint, command, or process tree. */
48
52
  readonly agentKind: string | null;
49
53
  /** Raw `@agent_state` (`"<state>:<epoch>"`), or null when unset. */
@@ -95,6 +99,7 @@ const AGENT_OPTIONS_FORMAT = [
95
99
  "#{@agent_display_name}",
96
100
  "#{@agent_hint}",
97
101
  "#{pane_pid}",
102
+ "#{@tmux_ide_team}",
98
103
  AGENT_LINE_SENTINEL,
99
104
  ].join(AGENT_FIELD_SEPARATOR);
100
105
 
@@ -119,6 +124,7 @@ export const SCRAPE_CAPTURE_BUDGET = 4;
119
124
  const SCRAPE_CACHE_MAX_ENTRIES = 1024;
120
125
 
121
126
  interface RawPaneOptions {
127
+ readonly team?: PaneTeamMembership;
122
128
  readonly stateRaw: string | null;
123
129
  readonly statusTextRaw: string | null;
124
130
  readonly displayNameRaw: string | null;
@@ -153,12 +159,13 @@ function parseAgentOptions(stdout: string): ReadonlyMap<string, RawPaneOptions>
153
159
  if (line.length === 0) continue;
154
160
  const fields = line.split(AGENT_FIELD_SEPARATOR);
155
161
  // paneId, state, statusText, displayName, hint, pid, sentinel
156
- if (fields.length !== 7 || fields[6] !== AGENT_LINE_SENTINEL) continue;
162
+ if (![7, 8].includes(fields.length) || fields.at(-1) !== AGENT_LINE_SENTINEL) continue;
157
163
  const runtimePaneId = fields[0]!;
158
164
  if (!RUNTIME_PANE_ID.test(runtimePaneId)) continue;
159
165
  const pidText = fields[5]!;
160
166
  const pid = /^[0-9]+$/u.test(pidText) ? Number(pidText) : null;
161
167
  result.set(runtimePaneId, {
168
+ team: fields.length === 8 ? readManualPaneTeam(fields[6], pid) : undefined,
162
169
  stateRaw: emptyToNull(fields[1]!),
163
170
  statusTextRaw: emptyToNull(fields[2]!),
164
171
  displayNameRaw: emptyToNull(fields[3]!),
@@ -171,6 +178,7 @@ function parseAgentOptions(stdout: string): ReadonlyMap<string, RawPaneOptions>
171
178
 
172
179
  export interface TmuxAgentStatusProbeDeps {
173
180
  readonly readTeamNames?: ClaudeTeamNameReader;
181
+ readonly readTeamMemberships?: ClaudeTeamMembershipReader;
174
182
  /** null proves no native dispatch; failures must never retry through stock capture. */
175
183
  readonly captureNative?: (
176
184
  pane: AgentStatusProbePane,
@@ -305,6 +313,7 @@ export function createTmuxAgentStatusProbe(deps: TmuxAgentStatusProbeDeps): Agen
305
313
  agentKind: string | null,
306
314
  ): void => {
307
315
  facts.set(pane.runtimePaneId, {
316
+ ...(raw?.team ? { team: raw.team } : {}),
308
317
  agentKind,
309
318
  agentStateRaw: raw?.stateRaw ?? null,
310
319
  agentStatusTextRaw: raw?.statusTextRaw ?? null,
@@ -432,6 +441,25 @@ export function createTmuxAgentStatusProbe(deps: TmuxAgentStatusProbeDeps): Agen
432
441
  emit(pane, raw, verdict, manifest.id);
433
442
  }
434
443
 
444
+ if (deps.readTeamMemberships) {
445
+ const memberships = await deps.readTeamMemberships(
446
+ input.panes.flatMap((pane) => {
447
+ const pid = options.get(pane.runtimePaneId)?.pid;
448
+ return pid ? [{ runtimePaneId: pane.runtimePaneId, pid }] : [];
449
+ }),
450
+ );
451
+ throwIfAborted(signal);
452
+ for (const [paneId, member] of memberships) {
453
+ const fact = facts.get(paneId);
454
+ if (fact)
455
+ facts.set(paneId, {
456
+ ...fact,
457
+ teamMemberName: member.name,
458
+ team: fact.team ?? member.team,
459
+ });
460
+ }
461
+ }
462
+
435
463
  if (deps.readTeamNames) {
436
464
  const names = await deps.readTeamNames(
437
465
  input.panes.flatMap((pane) => {
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { PaneTeamMembership } from "@tmux-ide/contracts";
1
3
  /** Read-only compatibility adapter for Claude Code's runtime team metadata.
2
4
  * Names are presentation evidence, never agent identity or permission authority.
3
5
  */
@@ -75,19 +77,35 @@ function matchesProcess(command: string, member: ClaudeTeamMember): boolean {
75
77
  return true;
76
78
  }
77
79
 
78
- export function resolveClaudeTeamName(
80
+ export function resolveClaudeTeamMember(
79
81
  pane: TeamPaneProcess,
80
82
  members: readonly ClaudeTeamMember[],
81
83
  processes: ProcEntry[],
82
- ): string | null {
84
+ ): ClaudeTeamMember | null {
83
85
  // A native pane id alone cannot bind a team: different servers reuse %N.
84
86
  // Its live shell PID scopes the match to this host and this pane's subtree.
85
87
  const subtree = subtreeEntries(processes, pane.pid);
86
88
  const matches = members.filter(
87
89
  (m) => m.paneId === pane.runtimePaneId && subtree.some((p) => matchesProcess(p.command, m)),
88
90
  );
89
- return matches.length === 1 ? matches[0]!.name : null;
91
+ return matches.length === 1 ? matches[0]! : null;
92
+ }
93
+
94
+ export function resolveClaudeTeamName(
95
+ pane: TeamPaneProcess,
96
+ members: readonly ClaudeTeamMember[],
97
+ processes: ProcEntry[],
98
+ ): string | null {
99
+ return resolveClaudeTeamMember(pane, members, processes)?.name ?? null;
100
+ }
101
+
102
+ export interface ClaudePaneTeam {
103
+ readonly name: string;
104
+ readonly team: PaneTeamMembership;
90
105
  }
106
+ export type ClaudeTeamMembershipReader = (
107
+ panes: readonly TeamPaneProcess[],
108
+ ) => Promise<ReadonlyMap<string, ClaudePaneTeam>>;
91
109
 
92
110
  async function readMembers(directory: string): Promise<ClaudeTeamMember[]> {
93
111
  try {
@@ -126,14 +144,14 @@ export type ClaudeTeamNameReader = (
126
144
  panes: readonly TeamPaneProcess[],
127
145
  ) => Promise<ReadonlyMap<string, string>>;
128
146
 
129
- export function createClaudeTeamNameReader(
147
+ export function createClaudeTeamMembershipReader(
130
148
  directory: string,
131
149
  deps: {
132
150
  readMembers?: () => Promise<ClaudeTeamMember[]>;
133
151
  readProcesses?: () => Promise<ProcEntry[]>;
134
152
  now?: () => number;
135
153
  } = {},
136
- ): ClaudeTeamNameReader {
154
+ ): ClaudeTeamMembershipReader {
137
155
  const now = deps.now ?? Date.now;
138
156
  let snapshot: { at: number; members: ClaudeTeamMember[]; processes: ProcEntry[] } | null = null;
139
157
  let pending: Promise<void> | null = null;
@@ -156,11 +174,28 @@ export function createClaudeTeamNameReader(
156
174
  await pending;
157
175
  }
158
176
  const current = snapshot!;
159
- const names = new Map<string, string>();
177
+ const names = new Map<string, ClaudePaneTeam>();
160
178
  for (const pane of panes) {
161
- const name = resolveClaudeTeamName(pane, current.members, current.processes);
162
- if (name) names.set(pane.runtimePaneId, name);
179
+ const member = resolveClaudeTeamMember(pane, current.members, current.processes);
180
+ if (member)
181
+ names.set(pane.runtimePaneId, {
182
+ name: member.name,
183
+ team: {
184
+ id: `team.${createHash("sha256").update(member.team).digest("hex").slice(0, 32)}`,
185
+ name: member.team.slice(0, 80),
186
+ source: "claude-code",
187
+ },
188
+ });
163
189
  }
164
190
  return names;
165
191
  };
166
192
  }
193
+
194
+ /** Compatibility API for callers interested only in the shared display name. */
195
+ export function createClaudeTeamNameReader(
196
+ directory: string,
197
+ deps: Parameters<typeof createClaudeTeamMembershipReader>[1] = {},
198
+ ): ClaudeTeamNameReader {
199
+ const read = createClaudeTeamMembershipReader(directory, deps);
200
+ return async (panes) => new Map([...(await read(panes))].map(([id, value]) => [id, value.name]));
201
+ }
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto";
2
+ import { PaneTeamMembershipSchemaZ, type PaneTeamMembership } from "@tmux-ide/contracts";
3
+
4
+ export const PANE_TEAM_OPTION = "@tmux_ide_team";
5
+
6
+ /** Pane-local metadata survives layout moves, but never a replacement process. */
7
+ export function manualPaneTeamStamp(name: string, pid: number): string {
8
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("Invalid pane process");
9
+ const team = PaneTeamMembershipSchemaZ.parse({
10
+ id: `team.${createHash("sha256").update(name).digest("hex").slice(0, 32)}`,
11
+ name,
12
+ source: "manual",
13
+ });
14
+ return `v1.${Buffer.from(JSON.stringify({ version: 1, pid, team })).toString("base64url")}`;
15
+ }
16
+
17
+ export function readManualPaneTeam(
18
+ raw: string | null | undefined,
19
+ pid: number | null,
20
+ ): PaneTeamMembership | undefined {
21
+ if (!raw || raw.length > 2048 || !pid) return undefined;
22
+ try {
23
+ if (!/^v1\.[A-Za-z0-9_-]+$/u.test(raw)) return undefined;
24
+ const value = JSON.parse(Buffer.from(raw.slice(3), "base64url").toString("utf8"));
25
+ if (value.version !== 1 || value.pid !== pid) return undefined;
26
+ const parsed = PaneTeamMembershipSchemaZ.safeParse(value.team);
27
+ return parsed.success && parsed.data.source === "manual" ? parsed.data : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
@@ -336,6 +336,7 @@ export function HomeAgentRoster(props: HomeAgentRosterProps) {
336
336
  theme={props.theme}
337
337
  id={`home-agent:${key}`}
338
338
  name={row().name}
339
+ team={row().team}
339
340
  context={[row().machineLabel, row().serverLabel, row().sessionName]
340
341
  .filter(Boolean)
341
342
  .join(" · ")}
@@ -1,3 +1,4 @@
1
+ import type { PaneTeamMembership } from "@tmux-ide/contracts";
1
2
  import type { NativePaneIdentity } from "@tmux-ide/contracts";
2
3
  import type {
3
4
  AgentActivity,
@@ -10,6 +11,7 @@ import type { ApplicationHomeCatalogSession } from "./application-home-catalog.t
10
11
  import { terminalAgentStatusLabel } from "./application-terminal-workspace-policy.ts";
11
12
 
12
13
  export interface HomeAgentRow {
14
+ readonly team?: PaneTeamMembership;
13
15
  readonly nativeIdentity: NativePaneIdentity | null;
14
16
  readonly interactionEndpoint: Extract<InteractionPaneEndpoint, { kind: "pane" }> | null;
15
17
  readonly key: string;
@@ -97,6 +99,7 @@ export function projectHomeAgentRows(
97
99
  resource.attachability.semanticPaneId === agent.paneId,
98
100
  )?.windowResourceId ?? null,
99
101
  name: agent.name,
102
+ ...(agent.team ? { team: agent.team } : {}),
100
103
  harness: agent.harness,
101
104
  activity: agent.activity,
102
105
  attention: agent.attention,
@@ -1,3 +1,4 @@
1
+ import { groupApplicationTeamRows } from "./application-team-groups.ts";
1
2
  import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
2
3
  import type {
3
4
  ApplicationMachineAgent,
@@ -84,6 +85,7 @@ export function projectHomeFleet(
84
85
  ? rows.filter((row) =>
85
86
  [
86
87
  row.name,
88
+ row.team?.name,
87
89
  row.harness,
88
90
  row.projectName,
89
91
  row.sessionName,
@@ -100,7 +102,7 @@ export function projectHomeFleet(
100
102
  ? "loading"
101
103
  : "unavailable"
102
104
  : "live",
103
- rows: matching,
105
+ rows: groupApplicationTeamRows(matching),
104
106
  observedSessions,
105
107
  totalSessions,
106
108
  loadingSessions,
@@ -1,3 +1,5 @@
1
+ import { groupApplicationTeamRows } from "./application-team-groups.ts";
2
+ import type { PaneTeamMembership } from "@tmux-ide/contracts";
1
3
  import type { NativePaneIdentity } from "@tmux-ide/contracts";
2
4
  import type { PaneInteractionEndpoint } from "../ui/pane-interaction-presentation.ts";
3
5
  import type { InteractionPaneEndpoint } from "@tmux-ide/contracts";
@@ -31,6 +33,7 @@ import { Surface } from "../ui/surface.tsx";
31
33
  import { useKeyboardRoute } from "../ui/keyboard-router.tsx";
32
34
 
33
35
  export interface ApplicationMachineAgent {
36
+ readonly team?: PaneTeamMembership;
34
37
  readonly nativeIdentity: NativePaneIdentity | null;
35
38
  readonly interactionEndpoint: Extract<InteractionPaneEndpoint, { kind: "pane" }> | null;
36
39
  readonly daemonInstanceId?: string;
@@ -168,7 +171,7 @@ export function ApplicationMachineSidebar(props: {
168
171
  const preferenceKey = (group: ApplicationMachineGroup) => group.environmentId ?? group.id;
169
172
  const rows = createMemo<readonly Row[]>(() => [
170
173
  ...props.model.groups().flatMap((group) =>
171
- (group.agents ?? []).map((agent) => ({
174
+ groupApplicationTeamRows(group.agents ?? []).map((agent) => ({
172
175
  key: JSON.stringify([
173
176
  group.id,
174
177
  "agent",
@@ -583,6 +586,7 @@ export function ApplicationMachineSidebar(props: {
583
586
  theme={props.theme}
584
587
  id={`machine:${row.key}`}
585
588
  name={agent().name}
589
+ team={agent().team}
586
590
  context={[
587
591
  row.group.label,
588
592
  row.group.sessions.find(
@@ -0,0 +1,40 @@
1
+ import {
2
+ paneTeamGroupKey,
3
+ type PaneTeamMembership,
4
+ type TmuxServerScope,
5
+ } from "@tmux-ide/contracts";
6
+
7
+ interface TeamRow {
8
+ readonly team?: PaneTeamMembership;
9
+ readonly machineId?: string;
10
+ readonly server?: TmuxServerScope;
11
+ readonly daemonInstanceId?: string;
12
+ }
13
+
14
+ export function applicationTeamKey(row: TeamRow): string | null {
15
+ return row.team
16
+ ? paneTeamGroupKey(
17
+ row.team,
18
+ row.machineId ?? "local",
19
+ row.server?.serverId ?? "default",
20
+ row.server?.generation ?? row.daemonInstanceId ?? "",
21
+ )
22
+ : null;
23
+ }
24
+
25
+ /** Group by membership, never by name, activity, or current tmux window. Stable within groups. */
26
+ export function groupApplicationTeamRows<T extends TeamRow>(rows: readonly T[]): T[] {
27
+ const groups = new Map<string, T[]>();
28
+ const ungrouped: T[] = [];
29
+ for (const row of rows) {
30
+ const key = applicationTeamKey(row);
31
+ if (key === null) {
32
+ ungrouped.push(row);
33
+ continue;
34
+ }
35
+ const group = groups.get(key) ?? [];
36
+ group.push(row);
37
+ groups.set(key, group);
38
+ }
39
+ return [...groups.values()].flat().concat(ungrouped);
40
+ }
@@ -5,7 +5,7 @@ import {
5
5
  type PaneInteractionEvent,
6
6
  } from "./pane-interaction-presentation.ts";
7
7
  /* @jsxImportSource @opentui/solid */
8
- import type { AgentActivity } from "@tmux-ide/contracts";
8
+ import type { AgentActivity, PaneTeamMembership } from "@tmux-ide/contracts";
9
9
  import { Show } from "solid-js";
10
10
  import type { SemanticThemeSnapshot } from "../theme.ts";
11
11
  import { clipTerminal } from "../terminal-text.ts";
@@ -19,6 +19,7 @@ export function AgentRow(props: {
19
19
  theme: SemanticThemeSnapshot;
20
20
  id: string;
21
21
  name: string;
22
+ team?: PaneTeamMembership;
22
23
  context: string;
23
24
  activity: AgentActivity;
24
25
  interaction?: PaneInteractionEvent;
@@ -83,7 +84,7 @@ export function AgentRow(props: {
83
84
  theme={props.theme}
84
85
  id={props.id}
85
86
  width={props.width}
86
- label={props.name}
87
+ label={props.compact && props.team ? `${props.team.name} / ${props.name}` : props.name}
87
88
  marker={
88
89
  receipt()
89
90
  ? receiptMarker()
@@ -113,7 +114,10 @@ export function AgentRow(props: {
113
114
  width={props.width}
114
115
  fg={props.selected ? palette().foreground : props.theme.roles.text.muted}
115
116
  >
116
- {clipTerminal(` ${props.context}`, props.width)}
117
+ {clipTerminal(
118
+ ` ${[props.team?.name, props.context].filter(Boolean).join(" · ")}`,
119
+ props.width,
120
+ )}
117
121
  </text>
118
122
  </Show>
119
123
  </box>