pi-ca-leash 0.10.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.
Files changed (138) hide show
  1. package/AGENTS.md +77 -0
  2. package/ARCHITECTURE.md +290 -0
  3. package/CHANGELOG.md +158 -0
  4. package/DEVELOPMENT.md +197 -0
  5. package/KNOWN_LIMITS.md +80 -0
  6. package/LICENSE +21 -0
  7. package/README.md +288 -0
  8. package/extensions/backend-tool-actions.test.ts +59 -0
  9. package/extensions/backend-tool-actions.ts +31 -0
  10. package/extensions/command-drivers.test.ts +37 -0
  11. package/extensions/command-drivers.ts +126 -0
  12. package/extensions/command-parity.test.ts +560 -0
  13. package/extensions/command-visibility.test.ts +21 -0
  14. package/extensions/command-visibility.ts +10 -0
  15. package/extensions/index.ts +3218 -0
  16. package/extensions/llm-tools.test.ts +537 -0
  17. package/extensions/model-catalog.test.ts +34 -0
  18. package/extensions/model-catalog.ts +173 -0
  19. package/extensions/peer-history.test.ts +141 -0
  20. package/extensions/peer-history.ts +90 -0
  21. package/extensions/peer-naming.test.ts +25 -0
  22. package/extensions/peer-naming.ts +129 -0
  23. package/extensions/peer-relay.test.ts +122 -0
  24. package/extensions/peer-relay.ts +83 -0
  25. package/extensions/peer-ux.test.ts +239 -0
  26. package/extensions/peer-ux.ts +327 -0
  27. package/extensions/persistence.test.ts +68 -0
  28. package/extensions/persistence.ts +67 -0
  29. package/extensions/prompts/extension-log-tool.md +5 -0
  30. package/extensions/prompts/peer-ask-tool.md +5 -0
  31. package/extensions/prompts/peer-bridge-system.md +4 -0
  32. package/extensions/prompts/peer-history-tool.md +3 -0
  33. package/extensions/prompts/peer-init-user-help.md +11 -0
  34. package/extensions/prompts/peer-init.md +17 -0
  35. package/extensions/prompts/peer-interrupt-tool.md +2 -0
  36. package/extensions/prompts/peer-list-tool.md +3 -0
  37. package/extensions/prompts/peer-no-babysitting.md +6 -0
  38. package/extensions/prompts/peer-send-tool.md +5 -0
  39. package/extensions/prompts/peer-start-tool.md +7 -0
  40. package/extensions/prompts/peer-stop-tool.md +3 -0
  41. package/extensions/prompts/runtime-models-tool.md +6 -0
  42. package/extensions/prompts/subagent-list-tool.md +3 -0
  43. package/extensions/prompts/subagent-run-tool.md +6 -0
  44. package/extensions/prompts/subagent-status-tool.md +2 -0
  45. package/extensions/prompts/team-list-tool.md +2 -0
  46. package/extensions/prompts/team-message-tool.md +2 -0
  47. package/extensions/prompts/team-spawn-tool.md +5 -0
  48. package/extensions/prompts/team-stop-tool.md +2 -0
  49. package/extensions/prompts/team-task-tool.md +3 -0
  50. package/extensions/prompts.ts +41 -0
  51. package/extensions/runtime-driver.test.ts +38 -0
  52. package/extensions/runtime-driver.ts +33 -0
  53. package/extensions/runtime-safety.test.ts +21 -0
  54. package/extensions/runtime-safety.ts +49 -0
  55. package/extensions/support.test.ts +144 -0
  56. package/extensions/support.ts +205 -0
  57. package/extensions/tool-inputs.test.ts +45 -0
  58. package/extensions/tool-inputs.ts +79 -0
  59. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/bridge.d.ts +48 -0
  60. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/bridge.js +406 -0
  61. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/bridge.js.map +1 -0
  62. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/cli.d.ts +2 -0
  63. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/cli.js +18 -0
  64. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/cli.js.map +1 -0
  65. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/index.d.ts +5 -0
  66. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/index.js +5 -0
  67. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/index.js.map +1 -0
  68. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/persistence.d.ts +12 -0
  69. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/persistence.js +31 -0
  70. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/persistence.js.map +1 -0
  71. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/pi-intercom-transport.d.ts +12 -0
  72. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/pi-intercom-transport.js +347 -0
  73. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/pi-intercom-transport.js.map +1 -0
  74. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/types.d.ts +103 -0
  75. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/types.js +2 -0
  76. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/types.js.map +1 -0
  77. package/node_modules/@pi-claude-code-agent/intercom-bridge/package.json +32 -0
  78. package/node_modules/@pi-claude-code-agent/runtime/dist/cli.d.ts +2 -0
  79. package/node_modules/@pi-claude-code-agent/runtime/dist/cli.js +26 -0
  80. package/node_modules/@pi-claude-code-agent/runtime/dist/cli.js.map +1 -0
  81. package/node_modules/@pi-claude-code-agent/runtime/dist/driver-config.d.ts +4 -0
  82. package/node_modules/@pi-claude-code-agent/runtime/dist/driver-config.js +12 -0
  83. package/node_modules/@pi-claude-code-agent/runtime/dist/driver-config.js.map +1 -0
  84. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/claude-sdk.d.ts +8 -0
  85. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/claude-sdk.js +320 -0
  86. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/claude-sdk.js.map +1 -0
  87. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/codex-cli.d.ts +24 -0
  88. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/codex-cli.js +266 -0
  89. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/codex-cli.js.map +1 -0
  90. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/messages.d.ts +72 -0
  91. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/messages.js +2 -0
  92. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/messages.js.map +1 -0
  93. package/node_modules/@pi-claude-code-agent/runtime/dist/index.d.ts +6 -0
  94. package/node_modules/@pi-claude-code-agent/runtime/dist/index.js +5 -0
  95. package/node_modules/@pi-claude-code-agent/runtime/dist/index.js.map +1 -0
  96. package/node_modules/@pi-claude-code-agent/runtime/dist/persistence.d.ts +16 -0
  97. package/node_modules/@pi-claude-code-agent/runtime/dist/persistence.js +94 -0
  98. package/node_modules/@pi-claude-code-agent/runtime/dist/persistence.js.map +1 -0
  99. package/node_modules/@pi-claude-code-agent/runtime/dist/runtime.d.ts +31 -0
  100. package/node_modules/@pi-claude-code-agent/runtime/dist/runtime.js +409 -0
  101. package/node_modules/@pi-claude-code-agent/runtime/dist/runtime.js.map +1 -0
  102. package/node_modules/@pi-claude-code-agent/runtime/dist/types.d.ts +185 -0
  103. package/node_modules/@pi-claude-code-agent/runtime/dist/types.js +2 -0
  104. package/node_modules/@pi-claude-code-agent/runtime/dist/types.js.map +1 -0
  105. package/node_modules/@pi-claude-code-agent/runtime/package.json +32 -0
  106. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/backend.d.ts +34 -0
  107. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/backend.js +327 -0
  108. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/backend.js.map +1 -0
  109. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/cli.d.ts +2 -0
  110. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/cli.js +17 -0
  111. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/cli.js.map +1 -0
  112. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/index.d.ts +4 -0
  113. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/index.js +4 -0
  114. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/index.js.map +1 -0
  115. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/persistence.d.ts +12 -0
  116. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/persistence.js +81 -0
  117. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/persistence.js.map +1 -0
  118. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/types.d.ts +72 -0
  119. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/types.js +2 -0
  120. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/types.js.map +1 -0
  121. package/node_modules/@pi-claude-code-agent/subagents-backend/package.json +32 -0
  122. package/node_modules/@pi-claude-code-agent/teams-backend/dist/backend.d.ts +27 -0
  123. package/node_modules/@pi-claude-code-agent/teams-backend/dist/backend.js +194 -0
  124. package/node_modules/@pi-claude-code-agent/teams-backend/dist/backend.js.map +1 -0
  125. package/node_modules/@pi-claude-code-agent/teams-backend/dist/cli.d.ts +2 -0
  126. package/node_modules/@pi-claude-code-agent/teams-backend/dist/cli.js +21 -0
  127. package/node_modules/@pi-claude-code-agent/teams-backend/dist/cli.js.map +1 -0
  128. package/node_modules/@pi-claude-code-agent/teams-backend/dist/index.d.ts +4 -0
  129. package/node_modules/@pi-claude-code-agent/teams-backend/dist/index.js +4 -0
  130. package/node_modules/@pi-claude-code-agent/teams-backend/dist/index.js.map +1 -0
  131. package/node_modules/@pi-claude-code-agent/teams-backend/dist/persistence.d.ts +8 -0
  132. package/node_modules/@pi-claude-code-agent/teams-backend/dist/persistence.js +66 -0
  133. package/node_modules/@pi-claude-code-agent/teams-backend/dist/persistence.js.map +1 -0
  134. package/node_modules/@pi-claude-code-agent/teams-backend/dist/types.d.ts +41 -0
  135. package/node_modules/@pi-claude-code-agent/teams-backend/dist/types.js +2 -0
  136. package/node_modules/@pi-claude-code-agent/teams-backend/dist/types.js.map +1 -0
  137. package/node_modules/@pi-claude-code-agent/teams-backend/package.json +33 -0
  138. package/package.json +98 -0
@@ -0,0 +1,173 @@
1
+ import type { RuntimeDriverName } from "@pi-claude-code-agent/runtime";
2
+
3
+ export interface RuntimeModelCatalogEntry {
4
+ id: string;
5
+ name: string;
6
+ contextWindow: number;
7
+ maxTokens: number;
8
+ reasoning: boolean;
9
+ inputModalities: string[];
10
+ inputCostPerMillion: number;
11
+ outputCostPerMillion: number;
12
+ }
13
+
14
+ export interface RuntimeDriverModelCatalog {
15
+ driver: RuntimeDriverName;
16
+ provider: "anthropic" | "openai-codex";
17
+ defaultModel: string;
18
+ aliases: Record<string, string>;
19
+ recommendations: RuntimeModelRecommendation[];
20
+ cli: string;
21
+ flag: string;
22
+ source: string;
23
+ models: RuntimeModelCatalogEntry[];
24
+ }
25
+
26
+ export interface RuntimeModelRecommendation {
27
+ alias: string;
28
+ model: string;
29
+ useCase: string;
30
+ }
31
+
32
+ export interface RuntimeModelSelection {
33
+ requestedModel?: string;
34
+ runtimeModel?: string;
35
+ note: string;
36
+ alias?: string;
37
+ entry?: RuntimeModelCatalogEntry;
38
+ }
39
+
40
+ const LANISTA_SOURCE = "lanista agents anthropic/codex";
41
+
42
+ export const RUNTIME_MODEL_CATALOGS: Record<RuntimeDriverName, RuntimeDriverModelCatalog> = {
43
+ "claude-sdk": {
44
+ driver: "claude-sdk",
45
+ provider: "anthropic",
46
+ defaultModel: "claude-opus-4-7",
47
+ aliases: {
48
+ haiku: "claude-haiku-4-5",
49
+ opus: "claude-opus-4-7",
50
+ sonnet: "claude-sonnet-4-6",
51
+ },
52
+ recommendations: [
53
+ { alias: "opus", model: "claude-opus-4-7", useCase: "architecture, hard review, long-context planning" },
54
+ { alias: "sonnet", model: "claude-sonnet-4-6", useCase: "coding, refactors, implementation reviews" },
55
+ { alias: "haiku", model: "claude-haiku-4-5", useCase: "quick checks, cheap parallel workers, summaries" },
56
+ ],
57
+ cli: "claude-code",
58
+ flag: "claude --model <id>",
59
+ source: LANISTA_SOURCE,
60
+ models: [
61
+ { id: "claude-3-5-haiku-20241022", name: "Claude Haiku 3.5", contextWindow: 200000, maxTokens: 8192, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 0.8, outputCostPerMillion: 4 },
62
+ { id: "claude-3-5-haiku-latest", name: "Claude Haiku 3.5 (latest)", contextWindow: 200000, maxTokens: 8192, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 0.8, outputCostPerMillion: 4 },
63
+ { id: "claude-3-5-sonnet-20240620", name: "Claude Sonnet 3.5", contextWindow: 200000, maxTokens: 8192, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
64
+ { id: "claude-3-5-sonnet-20241022", name: "Claude Sonnet 3.5 v2", contextWindow: 200000, maxTokens: 8192, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
65
+ { id: "claude-3-7-sonnet-20250219", name: "Claude Sonnet 3.7", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
66
+ { id: "claude-3-haiku-20240307", name: "Claude Haiku 3", contextWindow: 200000, maxTokens: 4096, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 0.25, outputCostPerMillion: 1.25 },
67
+ { id: "claude-3-opus-20240229", name: "Claude Opus 3", contextWindow: 200000, maxTokens: 4096, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 15, outputCostPerMillion: 75 },
68
+ { id: "claude-3-sonnet-20240229", name: "Claude Sonnet 3", contextWindow: 200000, maxTokens: 4096, reasoning: false, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
69
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (latest)", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1, outputCostPerMillion: 5 },
70
+ { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1, outputCostPerMillion: 5 },
71
+ { id: "claude-opus-4-0", name: "Claude Opus 4 (latest)", contextWindow: 200000, maxTokens: 32000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 15, outputCostPerMillion: 75 },
72
+ { id: "claude-opus-4-1", name: "Claude Opus 4.1 (latest)", contextWindow: 200000, maxTokens: 32000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 15, outputCostPerMillion: 75 },
73
+ { id: "claude-opus-4-1-20250805", name: "Claude Opus 4.1", contextWindow: 200000, maxTokens: 32000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 15, outputCostPerMillion: 75 },
74
+ { id: "claude-opus-4-20250514", name: "Claude Opus 4", contextWindow: 200000, maxTokens: 32000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 15, outputCostPerMillion: 75 },
75
+ { id: "claude-opus-4-5", name: "Claude Opus 4.5 (latest)", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 5, outputCostPerMillion: 25 },
76
+ { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 5, outputCostPerMillion: 25 },
77
+ { id: "claude-opus-4-6", name: "Claude Opus 4.6", contextWindow: 1000000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 5, outputCostPerMillion: 25 },
78
+ { id: "claude-opus-4-7", name: "Claude Opus 4.7", contextWindow: 1000000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 5, outputCostPerMillion: 25 },
79
+ { id: "claude-sonnet-4-0", name: "Claude Sonnet 4 (latest)", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
80
+ { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
81
+ { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (latest)", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
82
+ { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5", contextWindow: 200000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
83
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", contextWindow: 1000000, maxTokens: 64000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 3, outputCostPerMillion: 15 },
84
+ ],
85
+ },
86
+ "codex-cli": {
87
+ driver: "codex-cli",
88
+ provider: "openai-codex",
89
+ defaultModel: "gpt-5.5",
90
+ aliases: {
91
+ codex: "gpt-5.3-codex",
92
+ mini: "gpt-5.4-mini",
93
+ spark: "gpt-5.3-codex-spark",
94
+ },
95
+ recommendations: [
96
+ { alias: "default", model: "gpt-5.5", useCase: "architecture, hard debugging, broad repo analysis" },
97
+ { alias: "codex", model: "gpt-5.3-codex", useCase: "coding, tests, focused refactors" },
98
+ { alias: "mini", model: "gpt-5.4-mini", useCase: "quick edits, cheap exploration, small reviews" },
99
+ { alias: "spark", model: "gpt-5.3-codex-spark", useCase: "fast text-only worker tasks" },
100
+ ],
101
+ cli: "codex",
102
+ flag: "codex --model <id>",
103
+ source: LANISTA_SOURCE,
104
+ models: [
105
+ { id: "gpt-5.1", name: "GPT-5.1", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1.25, outputCostPerMillion: 10 },
106
+ { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1.25, outputCostPerMillion: 10 },
107
+ { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 0.25, outputCostPerMillion: 2 },
108
+ { id: "gpt-5.2", name: "GPT-5.2", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1.75, outputCostPerMillion: 14 },
109
+ { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1.75, outputCostPerMillion: 14 },
110
+ { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 1.75, outputCostPerMillion: 14 },
111
+ { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", contextWindow: 128000, maxTokens: 128000, reasoning: true, inputModalities: ["text"], inputCostPerMillion: 0, outputCostPerMillion: 0 },
112
+ { id: "gpt-5.4", name: "GPT-5.4", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 2.5, outputCostPerMillion: 15 },
113
+ { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 0.75, outputCostPerMillion: 4.5 },
114
+ { id: "gpt-5.5", name: "GPT-5.5", contextWindow: 272000, maxTokens: 128000, reasoning: true, inputModalities: ["text", "image"], inputCostPerMillion: 5, outputCostPerMillion: 30 },
115
+ ],
116
+ },
117
+ };
118
+
119
+ export function modelCatalogsForDriver(driver?: RuntimeDriverName): RuntimeDriverModelCatalog[] {
120
+ if (driver) {
121
+ return [RUNTIME_MODEL_CATALOGS[driver]];
122
+ }
123
+ return [RUNTIME_MODEL_CATALOGS["claude-sdk"], RUNTIME_MODEL_CATALOGS["codex-cli"]];
124
+ }
125
+
126
+ export function findRuntimeModel(driver: RuntimeDriverName, model: string): RuntimeModelCatalogEntry | undefined {
127
+ const normalized = model.trim();
128
+ return RUNTIME_MODEL_CATALOGS[driver].models.find((entry) => entry.id === normalized);
129
+ }
130
+
131
+ export function resolveRuntimeModelSelection(driver: RuntimeDriverName, model?: string): RuntimeModelSelection {
132
+ const catalog = RUNTIME_MODEL_CATALOGS[driver];
133
+ const requestedModel = model?.trim() || undefined;
134
+ if (!requestedModel) {
135
+ return {
136
+ runtimeModel: undefined,
137
+ note: `model default ${catalog.defaultModel}`,
138
+ };
139
+ }
140
+
141
+ const aliasTarget = catalog.aliases[requestedModel.toLowerCase()];
142
+ const runtimeModel = aliasTarget ?? requestedModel;
143
+ const entry = findRuntimeModel(driver, runtimeModel);
144
+ if (!entry) {
145
+ return {
146
+ requestedModel,
147
+ runtimeModel,
148
+ note: `model ${requestedModel} not in bundled ${catalog.provider} catalog; passing through to runtime`,
149
+ };
150
+ }
151
+
152
+ const entryNote = `model ${entry.id} (${entry.name}, context window ${entry.contextWindow}, max output ${entry.maxTokens})`;
153
+ if (aliasTarget) {
154
+ return {
155
+ requestedModel,
156
+ runtimeModel,
157
+ alias: requestedModel,
158
+ entry,
159
+ note: `model alias ${requestedModel} -> ${entryNote}`,
160
+ };
161
+ }
162
+
163
+ return {
164
+ requestedModel,
165
+ runtimeModel,
166
+ entry,
167
+ note: entryNote,
168
+ };
169
+ }
170
+
171
+ export function describeModelSelection(driver: RuntimeDriverName, model?: string): string | undefined {
172
+ return resolveRuntimeModelSelection(driver, model).note;
173
+ }
@@ -0,0 +1,141 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { RuntimeEvent } from "@pi-claude-code-agent/runtime";
4
+ import { formatPeerHistoryPage } from "./peer-history.js";
5
+
6
+ function makeEvent(event: Partial<RuntimeEvent> & Pick<RuntimeEvent, "type">): RuntimeEvent {
7
+ return {
8
+ id: `event-${event.type}`,
9
+ sessionId: "session-1",
10
+ sequence: 1,
11
+ timestamp: "2026-05-01T19:14:05.000Z",
12
+ ...event,
13
+ } as RuntimeEvent;
14
+ }
15
+
16
+ test("peer history hides thinking and shows visible transcript items", () => {
17
+ const page = formatPeerHistoryPage([
18
+ makeEvent({
19
+ type: "message",
20
+ sequence: 1,
21
+ role: "assistant",
22
+ message: {
23
+ role: "assistant",
24
+ blocks: [{ type: "thinking", text: "secret" }, { type: "text", text: "Ready." }],
25
+ },
26
+ }),
27
+ makeEvent({
28
+ type: "tool",
29
+ sequence: 2,
30
+ phase: "requested",
31
+ toolName: "Bash",
32
+ input: { command: "pwd" },
33
+ }),
34
+ makeEvent({
35
+ type: "tool",
36
+ sequence: 3,
37
+ phase: "completed",
38
+ toolName: "Bash",
39
+ output: { stdout: "/repo" },
40
+ }),
41
+ makeEvent({
42
+ type: "result",
43
+ sequence: 4,
44
+ ok: true,
45
+ summary: "Ready.",
46
+ }),
47
+ ]);
48
+
49
+ assert.match(page.text, /\[1 \d{2}:\d{2}:\d{2}\] assistant\nReady\./);
50
+ assert.match(page.text, /\[2 \d{2}:\d{2}:\d{2}\] tool requested Bash/);
51
+ assert.match(page.text, /"command": "pwd"/);
52
+ assert.match(page.text, /\[3 \d{2}:\d{2}:\d{2}\] tool completed Bash/);
53
+ assert.doesNotMatch(page.text, /secret/);
54
+ assert.doesNotMatch(page.text, /\[4 19:14:05\] result/);
55
+ });
56
+
57
+ test("peer history paginates by visible entries from tail by default and exposes cursors", () => {
58
+ const events = Array.from({ length: 5 }, (_, index) => makeEvent({
59
+ type: "message",
60
+ sequence: index + 1,
61
+ message: {
62
+ role: "assistant",
63
+ blocks: [{ type: "text", text: `msg-${index + 1}` }],
64
+ },
65
+ role: "assistant",
66
+ }));
67
+
68
+ const page = formatPeerHistoryPage(events, { limit: 2 });
69
+ assert.equal(page.startCursor, 3);
70
+ assert.equal(page.endCursor, 5);
71
+ assert.equal(page.previousCursor, 1);
72
+ assert.equal(page.nextCursor, undefined);
73
+ assert.match(page.text, /msg-4/);
74
+ assert.match(page.text, /msg-5/);
75
+ assert.doesNotMatch(page.text, /msg-3/);
76
+ });
77
+
78
+ test("peer history cursor ignores hidden events and empty visible ranges", () => {
79
+ const page = formatPeerHistoryPage([
80
+ makeEvent({
81
+ type: "message",
82
+ sequence: 1,
83
+ role: "assistant",
84
+ message: {
85
+ role: "assistant",
86
+ blocks: [{ type: "thinking", text: "secret" }],
87
+ },
88
+ }),
89
+ makeEvent({
90
+ type: "result",
91
+ sequence: 2,
92
+ ok: true,
93
+ summary: "done",
94
+ }),
95
+ ], { cursor: 0, limit: 2 });
96
+
97
+ assert.equal(page.startCursor, 0);
98
+ assert.equal(page.endCursor, 0);
99
+ assert.equal(page.text, "<no visible transcript items in this range>");
100
+ });
101
+
102
+ test("peer history limit counts visible entries rather than raw events", () => {
103
+ const page = formatPeerHistoryPage([
104
+ makeEvent({
105
+ type: "message",
106
+ sequence: 1,
107
+ role: "assistant",
108
+ message: {
109
+ role: "assistant",
110
+ blocks: [{ type: "text", text: "msg-1" }],
111
+ },
112
+ }),
113
+ makeEvent({
114
+ type: "result",
115
+ sequence: 2,
116
+ ok: true,
117
+ summary: "done-1",
118
+ }),
119
+ makeEvent({
120
+ type: "message",
121
+ sequence: 3,
122
+ role: "assistant",
123
+ message: {
124
+ role: "assistant",
125
+ blocks: [{ type: "text", text: "msg-2" }],
126
+ },
127
+ }),
128
+ makeEvent({
129
+ type: "result",
130
+ sequence: 4,
131
+ ok: true,
132
+ summary: "done-2",
133
+ }),
134
+ ], { limit: 2 });
135
+
136
+ assert.equal(page.total, 2);
137
+ assert.equal(page.startCursor, 0);
138
+ assert.equal(page.endCursor, 2);
139
+ assert.match(page.text, /msg-1/);
140
+ assert.match(page.text, /msg-2/);
141
+ });
@@ -0,0 +1,90 @@
1
+ import type { RuntimeEvent, RuntimeMessageBlock } from "@pi-claude-code-agent/runtime";
2
+
3
+ export interface PeerHistoryPage {
4
+ startCursor: number;
5
+ endCursor: number;
6
+ total: number;
7
+ previousCursor?: number;
8
+ nextCursor?: number;
9
+ text: string;
10
+ }
11
+
12
+ export function formatPeerHistoryPage(
13
+ events: RuntimeEvent[],
14
+ options: { cursor?: number; limit?: number } = {},
15
+ ): PeerHistoryPage {
16
+ const entries = events
17
+ .map(formatTranscriptEvent)
18
+ .filter((value): value is string => Boolean(value));
19
+ const total = entries.length;
20
+ const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 20)));
21
+ const startCursor = options.cursor == null
22
+ ? Math.max(0, total - limit)
23
+ : Math.max(0, Math.min(total, Math.trunc(options.cursor)));
24
+ const endCursor = Math.min(total, startCursor + limit);
25
+ const page = entries.slice(startCursor, endCursor);
26
+
27
+ return {
28
+ startCursor,
29
+ endCursor,
30
+ total,
31
+ previousCursor: startCursor > 0 ? Math.max(0, startCursor - limit) : undefined,
32
+ nextCursor: endCursor < total ? endCursor : undefined,
33
+ text: page.join("\n\n") || "<no visible transcript items in this range>",
34
+ };
35
+ }
36
+
37
+ function formatTranscriptEvent(event: RuntimeEvent): string | undefined {
38
+ switch (event.type) {
39
+ case "message": {
40
+ const text = event.message.blocks
41
+ .map(blockToVisibleText)
42
+ .filter((value): value is string => Boolean(value && value.trim()))
43
+ .join("\n\n")
44
+ .trim();
45
+ if (!text) {
46
+ return undefined;
47
+ }
48
+ return [`[${event.sequence} ${formatIsoTime(event.timestamp)}] ${event.role}`, text].join("\n");
49
+ }
50
+ case "tool": {
51
+ const payload = event.phase === "requested" ? event.input : event.output;
52
+ const body = formatPayload(payload);
53
+ return [`[${event.sequence} ${formatIsoTime(event.timestamp)}] tool ${event.phase} ${event.toolName}`, body].filter(Boolean).join("\n");
54
+ }
55
+ case "error":
56
+ return [`[${event.sequence} ${formatIsoTime(event.timestamp)}] error`, event.message].join("\n");
57
+ default:
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ function blockToVisibleText(block: RuntimeMessageBlock): string | undefined {
63
+ if (block.type === "thinking") {
64
+ return undefined;
65
+ }
66
+ return typeof block.text === "string" ? block.text : undefined;
67
+ }
68
+
69
+ function formatPayload(value: unknown): string | undefined {
70
+ if (value == null) {
71
+ return undefined;
72
+ }
73
+ if (typeof value === "string") {
74
+ return truncate(value, 600);
75
+ }
76
+ try {
77
+ return truncate(JSON.stringify(value, null, 2), 600);
78
+ } catch {
79
+ return truncate(String(value), 600);
80
+ }
81
+ }
82
+
83
+ function truncate(value: string, max: number): string {
84
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
85
+ }
86
+
87
+ function formatIsoTime(timestamp: string): string {
88
+ const date = new Date(timestamp);
89
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
90
+ }
@@ -0,0 +1,25 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { derivePeerName, parsePeerStartInput } from "./peer-naming.js";
4
+
5
+ test("parsePeerStartInput supports prompt-only auto-naming", () => {
6
+ assert.deepEqual(parsePeerStartInput("Investigate flaky tests"), {
7
+ prompt: "Investigate flaky tests",
8
+ autoNamed: true,
9
+ });
10
+ });
11
+
12
+ test("parsePeerStartInput preserves explicit name override", () => {
13
+ assert.deepEqual(parsePeerStartInput("reviewer | Review auth flow"), {
14
+ name: "reviewer",
15
+ prompt: "Review auth flow",
16
+ autoNamed: false,
17
+ });
18
+ });
19
+
20
+ test("derivePeerName uses readable role heuristics and deconflicts", () => {
21
+ assert.equal(derivePeerName("Please review the auth flow", []), "reviewer");
22
+ assert.equal(derivePeerName("Investigate flaky tests", ["researcher"]), "researcher-2");
23
+ assert.equal(derivePeerName("You are a brief worker. Reply briefly.", []), "worker");
24
+ assert.equal(derivePeerName("Help with this", ["peer"]), "peer-2");
25
+ });
@@ -0,0 +1,129 @@
1
+ const ROLE_KEYWORDS: Array<{ tokens: string[]; name: string }> = [
2
+ { tokens: ["review", "reviewer"], name: "reviewer" },
3
+ { tokens: ["fix", "bug", "debug", "repair"], name: "fixer" },
4
+ { tokens: ["research", "investigate", "explore"], name: "researcher" },
5
+ { tokens: ["test", "qa", "verify"], name: "tester" },
6
+ { tokens: ["build", "compile"], name: "builder" },
7
+ { tokens: ["document", "docs", "write"], name: "writer" },
8
+ { tokens: ["plan", "design"], name: "planner" },
9
+ { tokens: ["search", "find", "grep"], name: "searcher" },
10
+ { tokens: ["refactor", "cleanup", "clean"], name: "cleaner" },
11
+ ];
12
+
13
+ const STOP_WORDS = new Set([
14
+ "a",
15
+ "an",
16
+ "and",
17
+ "are",
18
+ "as",
19
+ "be",
20
+ "brief",
21
+ "can",
22
+ "code",
23
+ "concise",
24
+ "direct",
25
+ "do",
26
+ "exactly",
27
+ "file",
28
+ "for",
29
+ "from",
30
+ "help",
31
+ "in",
32
+ "into",
33
+ "is",
34
+ "it",
35
+ "me",
36
+ "my",
37
+ "of",
38
+ "on",
39
+ "or",
40
+ "please",
41
+ "prompt",
42
+ "reply",
43
+ "role",
44
+ "task",
45
+ "that",
46
+ "the",
47
+ "this",
48
+ "to",
49
+ "use",
50
+ "with",
51
+ "you",
52
+ "your",
53
+ ]);
54
+
55
+ export interface ParsedPeerStartInput {
56
+ name?: string;
57
+ prompt?: string;
58
+ autoNamed: boolean;
59
+ }
60
+
61
+ export function parsePeerStartInput(args: string): ParsedPeerStartInput {
62
+ const parts = args.split("|").map((part) => part.trim()).filter(Boolean);
63
+ if (parts.length === 0) {
64
+ return { autoNamed: false };
65
+ }
66
+ if (parts.length === 1) {
67
+ return {
68
+ prompt: parts[0],
69
+ autoNamed: true,
70
+ };
71
+ }
72
+ return {
73
+ name: parts[0],
74
+ prompt: parts.slice(1).join(" | "),
75
+ autoNamed: false,
76
+ };
77
+ }
78
+
79
+ export function derivePeerName(prompt: string, existingNames: Iterable<string>): string {
80
+ const existing = new Set([...existingNames].map((name) => name.toLowerCase()));
81
+ const normalized = prompt.toLowerCase();
82
+
83
+ for (const candidate of ROLE_KEYWORDS) {
84
+ if (candidate.tokens.some((token) => normalized.includes(token))) {
85
+ return ensureUniqueName(candidate.name, existing);
86
+ }
87
+ }
88
+
89
+ const roleMatch = normalized.match(/you are (?:an? )?(?:\w+\s+){0,2}(\w+)/i);
90
+ const roleToken = sanitizeToken(roleMatch?.[1]);
91
+ if (roleToken && !STOP_WORDS.has(roleToken)) {
92
+ return ensureUniqueName(roleToken, existing);
93
+ }
94
+
95
+ const tokens = normalized
96
+ .split(/[^a-z0-9]+/)
97
+ .map((token) => sanitizeToken(token))
98
+ .filter((token): token is string => Boolean(token && !STOP_WORDS.has(token)));
99
+
100
+ for (const token of tokens) {
101
+ if (token.length >= 3) {
102
+ return ensureUniqueName(token, existing);
103
+ }
104
+ }
105
+
106
+ return ensureUniqueName("peer", existing);
107
+ }
108
+
109
+ function ensureUniqueName(base: string, existing: Set<string>): string {
110
+ const normalizedBase = sanitizeToken(base) ?? "peer";
111
+ if (!existing.has(normalizedBase)) {
112
+ return normalizedBase;
113
+ }
114
+ for (let suffix = 2; suffix < 1000; suffix += 1) {
115
+ const candidate = `${normalizedBase}-${suffix}`;
116
+ if (!existing.has(candidate)) {
117
+ return candidate;
118
+ }
119
+ }
120
+ return `${normalizedBase}-${Date.now()}`;
121
+ }
122
+
123
+ function sanitizeToken(value: string | undefined): string | undefined {
124
+ if (!value) {
125
+ return undefined;
126
+ }
127
+ const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
128
+ return normalized || undefined;
129
+ }
@@ -0,0 +1,122 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ createPeerRelaySnapshot,
5
+ formatPeerCompletionTurn,
6
+ formatQuotedTextBlock,
7
+ shouldForceRelayPeerCompletion,
8
+ shouldRelayPeerCompletion,
9
+ } from "./peer-relay.ts";
10
+
11
+ test("peer relay ignores initial seeded terminal state", () => {
12
+ const current = createPeerRelaySnapshot({
13
+ sessionId: "session-1",
14
+ state: "idle",
15
+ updatedAt: "2026-05-01T19:14:05.000Z",
16
+ });
17
+
18
+ assert.equal(shouldRelayPeerCompletion(undefined, current), false);
19
+ assert.equal(shouldForceRelayPeerCompletion(undefined, current), true);
20
+ });
21
+
22
+ test("peer relay fires when terminal snapshot changes after seed", () => {
23
+ const previous = createPeerRelaySnapshot({
24
+ sessionId: "session-1",
25
+ state: "idle",
26
+ updatedAt: "2026-05-01T19:14:05.000Z",
27
+ });
28
+ const current = createPeerRelaySnapshot({
29
+ sessionId: "session-1",
30
+ state: "waiting",
31
+ updatedAt: "2026-05-01T19:20:00.000Z",
32
+ });
33
+
34
+ assert.equal(shouldRelayPeerCompletion(previous, current), true);
35
+ assert.equal(shouldForceRelayPeerCompletion(previous, current), true);
36
+ });
37
+
38
+ test("peer relay ignores timestamp-only change for stable state and message", () => {
39
+ const previous = createPeerRelaySnapshot({
40
+ sessionId: "session-1",
41
+ state: "idle",
42
+ updatedAt: "2026-05-01T19:14:05.000Z",
43
+ messageText: "All clear.",
44
+ });
45
+ const current = createPeerRelaySnapshot({
46
+ sessionId: "session-1",
47
+ state: "idle",
48
+ updatedAt: "2026-05-01T19:20:00.000Z",
49
+ messageText: "All clear.",
50
+ });
51
+
52
+ assert.equal(shouldRelayPeerCompletion(previous, current), false);
53
+ assert.equal(shouldForceRelayPeerCompletion(previous, current), false);
54
+ });
55
+
56
+ test("peer relay fires on same-state new visible reply", () => {
57
+ const previous = createPeerRelaySnapshot({
58
+ sessionId: "session-1",
59
+ state: "idle",
60
+ updatedAt: "2026-05-01T19:14:05.000Z",
61
+ messageText: "All clear.",
62
+ });
63
+ const current = createPeerRelaySnapshot({
64
+ sessionId: "session-1",
65
+ state: "idle",
66
+ updatedAt: "2026-05-01T19:14:05.000Z",
67
+ messageText: "Done. Tests pass now.",
68
+ });
69
+
70
+ assert.equal(shouldRelayPeerCompletion(previous, current), true);
71
+ assert.equal(shouldForceRelayPeerCompletion(previous, current), true);
72
+ });
73
+
74
+ test("peer relay ignores unchanged or non-relayable snapshots", () => {
75
+ const previous = createPeerRelaySnapshot({
76
+ sessionId: "session-1",
77
+ state: "idle",
78
+ updatedAt: "2026-05-01T19:14:05.000Z",
79
+ });
80
+
81
+ assert.equal(shouldRelayPeerCompletion(previous, previous), false);
82
+ assert.equal(
83
+ shouldRelayPeerCompletion(
84
+ previous,
85
+ createPeerRelaySnapshot({
86
+ sessionId: "session-1",
87
+ state: "busy",
88
+ updatedAt: "2026-05-01T19:20:00.000Z",
89
+ }),
90
+ ),
91
+ false,
92
+ );
93
+ });
94
+
95
+ test("quoted text block uses fenced raw text", () => {
96
+ assert.equal(
97
+ formatQuotedTextBlock("All clear."),
98
+ ["```text", "All clear.", "```"].join("\n"),
99
+ );
100
+ assert.equal(
101
+ formatQuotedTextBlock("Contains ``` fence"),
102
+ ["````text", "Contains ``` fence", "````"].join("\n"),
103
+ );
104
+ });
105
+
106
+ test("peer relay formats wrapped main-turn prompt", () => {
107
+ const message = formatPeerCompletionTurn({
108
+ peerName: "reviewer",
109
+ state: "idle",
110
+ sessionId: "573f279f-30ec-4300-b948-da05f5d3007f",
111
+ message: "All clear.",
112
+ });
113
+
114
+ assert.match(message, /\[peer_update name=reviewer state=idle session=573f279f-30e\]/);
115
+ assert.match(message, /Automated peer update\./);
116
+ assert.match(message, /Peer: reviewer/);
117
+ assert.match(message, /State: idle/);
118
+ assert.match(message, /Peer reviewer finished\./);
119
+ assert.match(message, /Latest peer message:\n```text\nAll clear\.\n```/);
120
+ assert.match(message, /Use this as internal orchestration context\./);
121
+ assert.match(message, /Do not quote this wrapper verbatim to the user\./);
122
+ });