@tea-agent/loop-agent 0.17.0 → 0.17.2

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.
@@ -2,9 +2,11 @@
2
2
  * Pi readiness gate for Official Console Happy Path.
3
3
  * setup-required | ready | degraded — no pure form fallback.
4
4
  */
5
+ import path from "node:path";
5
6
  const SETUP_BLOCKED = Object.freeze([
6
7
  "newTask",
7
8
  "importPrd",
9
+ "bootstrapFromPrd",
8
10
  "contractApply",
9
11
  "contractAdopt",
10
12
  "dagRunTask",
@@ -12,6 +14,7 @@ const SETUP_BLOCKED = Object.freeze([
12
14
  "prepareDagConfirmation",
13
15
  "interviewStart",
14
16
  "interviewTurn",
17
+ "interviewSkip",
15
18
  ]);
16
19
  const DEGRADED_BLOCKED = Object.freeze([
17
20
  "contractApply",
@@ -20,6 +23,13 @@ const DEGRADED_BLOCKED = Object.freeze([
20
23
  "runDag",
21
24
  // new Interview may resume; brand-new apply/run still blocked
22
25
  ]);
26
+ /** Default Interview / DAG model matrix (provider, modelId). */
27
+ const INTERVIEW_MODEL_CANDIDATES = Object.freeze([
28
+ ["openai", "gpt-5.5"],
29
+ ["openai", "gpt-5.3-codex-spark"],
30
+ ["zai", "glm-5.2"],
31
+ ["zhipu", "glm-5.2"],
32
+ ]);
23
33
  export function classifyPiReadiness(probe) {
24
34
  if (!probe.sdkAvailable ||
25
35
  !probe.authReady ||
@@ -57,30 +67,194 @@ export function isActionBlockedByReadiness(state, action) {
57
67
  }
58
68
  return DEGRADED_BLOCKED.includes(action);
59
69
  }
70
+ function countAvailableModels(snapshot) {
71
+ return Array.isArray(snapshot.available) ? snapshot.available.length : 0;
72
+ }
73
+ function hasProviderConfig(snapshot) {
74
+ const authKeys = snapshot.auth && typeof snapshot.auth === "object"
75
+ ? Object.keys(snapshot.auth).length
76
+ : 0;
77
+ const configured = snapshot.configuredProviders &&
78
+ typeof snapshot.configuredProviders === "object"
79
+ ? Object.keys(snapshot.configuredProviders).length
80
+ : 0;
81
+ const stored = snapshot.storedProviders && typeof snapshot.storedProviders === "object"
82
+ ? Object.keys(snapshot.storedProviders).length
83
+ : 0;
84
+ return authKeys > 0 || configured > 0 || stored > 0;
85
+ }
60
86
  /**
61
- * Default probe for process boot. Without a real Pi ModelRuntime injection,
62
- * Official Console is setup-required (no form fallback).
63
- * Tests and server may inject a readiness probe override.
87
+ * Discover live Pi SDK / auth / model readiness via ModelRuntime.
88
+ * Kept inside worker/console (dynamic import) so Worker does not depend on
89
+ * executors; contract matches pi-sdk-executor 0.80.10.
64
90
  */
65
- export async function probePiReadiness(options) {
66
- if (options?.override) {
67
- return buildPiReadinessReport(options.override);
91
+ export async function discoverPiReadinessProbe() {
92
+ let sdk;
93
+ try {
94
+ sdk = (await import("@earendil-works/pi-coding-agent"));
95
+ }
96
+ catch (error) {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ return {
99
+ sdkAvailable: false,
100
+ authReady: false,
101
+ modelsAvailable: false,
102
+ interviewSessionOk: false,
103
+ details: {
104
+ sdkMessage: `Pi SDK import failed: ${message}`,
105
+ authMessage: "skipped (sdk unavailable)",
106
+ modelsMessage: "skipped (sdk unavailable)",
107
+ sessionMessage: "skipped (sdk unavailable)",
108
+ },
109
+ };
110
+ }
111
+ const ModelRuntime = sdk.ModelRuntime;
112
+ if (typeof sdk.createAgentSession !== "function" ||
113
+ typeof sdk.getAgentDir !== "function" ||
114
+ typeof ModelRuntime?.create !== "function") {
115
+ return {
116
+ sdkAvailable: false,
117
+ authReady: false,
118
+ modelsAvailable: false,
119
+ interviewSessionOk: false,
120
+ details: {
121
+ sdkMessage: "Pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.80.10)",
122
+ authMessage: "skipped (sdk incompatible)",
123
+ modelsMessage: "skipped (sdk incompatible)",
124
+ sessionMessage: "skipped (sdk incompatible)",
125
+ },
126
+ };
127
+ }
128
+ let agentDir;
129
+ try {
130
+ agentDir = sdk.getAgentDir();
131
+ }
132
+ catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ return {
135
+ sdkAvailable: false,
136
+ authReady: false,
137
+ modelsAvailable: false,
138
+ interviewSessionOk: false,
139
+ details: {
140
+ sdkMessage: `getAgentDir failed: ${message}`,
141
+ authMessage: "skipped",
142
+ modelsMessage: "skipped",
143
+ sessionMessage: "skipped",
144
+ },
145
+ };
146
+ }
147
+ let runtime;
148
+ try {
149
+ runtime = await ModelRuntime.create({
150
+ authPath: path.join(agentDir, "auth.json"),
151
+ modelsPath: path.join(agentDir, "models.json"),
152
+ });
153
+ }
154
+ catch (error) {
155
+ const message = error instanceof Error ? error.message : String(error);
156
+ return {
157
+ sdkAvailable: true,
158
+ authReady: false,
159
+ modelsAvailable: false,
160
+ interviewSessionOk: false,
161
+ details: {
162
+ sdkMessage: "Pi SDK 0.80.10 contract available",
163
+ authMessage: `ModelRuntime.create failed: ${message}`,
164
+ modelsMessage: "skipped (runtime init failed)",
165
+ sessionMessage: "skipped (runtime init failed)",
166
+ },
167
+ };
168
+ }
169
+ const snapshot = runtime.snapshot ?? {};
170
+ const availableCount = countAvailableModels(snapshot);
171
+ const providerConfigured = hasProviderConfig(snapshot);
172
+ const resolvedInterviewModels = [];
173
+ if (typeof runtime.getModel === "function") {
174
+ for (const [provider, modelId] of INTERVIEW_MODEL_CANDIDATES) {
175
+ try {
176
+ if (runtime.getModel(provider, modelId)) {
177
+ resolvedInterviewModels.push(`${provider}/${modelId}`);
178
+ }
179
+ }
180
+ catch {
181
+ // ignore per-model resolution errors
182
+ }
183
+ }
184
+ }
185
+ const authReady = availableCount > 0 || providerConfigured;
186
+ const modelsAvailable = availableCount > 0 || resolvedInterviewModels.length > 0;
187
+ let interviewSessionOk = false;
188
+ let sessionMessage;
189
+ if (!authReady || !modelsAvailable) {
190
+ sessionMessage =
191
+ "Interview session preflight skipped (auth/models not ready)";
192
+ }
193
+ else if (typeof sdk.SessionManager?.inMemory !== "function") {
194
+ sessionMessage =
195
+ "SessionManager.inMemory unavailable; Interview session preflight failed";
68
196
  }
69
- // Phase 2: real Pi SDK/auth/model discovery is optional injection.
70
- // Default fail-closed to setup-required so Happy Path cannot skip Interview.
71
- const probe = {
72
- sdkAvailable: false,
73
- authReady: false,
74
- modelsAvailable: false,
75
- interviewSessionOk: false,
197
+ else {
198
+ try {
199
+ sdk.SessionManager.inMemory(process.cwd());
200
+ interviewSessionOk = true;
201
+ sessionMessage = `Interview session preflight ok (${availableCount} available model(s); matrix=${resolvedInterviewModels.join(",") || "none"})`;
202
+ }
203
+ catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ sessionMessage = `Interview session preflight failed: ${message}`;
206
+ }
207
+ }
208
+ return {
209
+ sdkAvailable: true,
210
+ authReady,
211
+ modelsAvailable,
212
+ interviewSessionOk,
76
213
  details: {
77
- sdkMessage: "Pi SDK/ModelRuntime not injected; configure Interview model/auth (setup-required)",
78
- authMessage: "no credential probe configured",
79
- modelsMessage: "no model list available",
80
- sessionMessage: "Interview session preflight not run",
214
+ sdkMessage: `Pi SDK 0.80.10 contract available (agentDir=${agentDir})`,
215
+ authMessage: authReady
216
+ ? `credentials/providers ready (available=${availableCount}, providerConfig=${providerConfigured})`
217
+ : "no available models and no stored/configured provider credentials (set Pi auth or provider API keys)",
218
+ modelsMessage: modelsAvailable
219
+ ? `models ready (available=${availableCount}, interviewMatrix=${resolvedInterviewModels.join(",") || "none"})`
220
+ : "no available models for Interview",
221
+ sessionMessage,
81
222
  },
82
223
  };
83
- return buildPiReadinessReport(probe);
224
+ }
225
+ /**
226
+ * Default probe for process boot. Discovers live Pi ModelRuntime when no
227
+ * override is injected. Tests inject override for determinism.
228
+ */
229
+ export async function probePiReadiness(options) {
230
+ if (options?.override) {
231
+ return buildPiReadinessReport(options.override);
232
+ }
233
+ try {
234
+ const probe = await discoverPiReadinessProbe();
235
+ if (options?.siblingControllerOk && !probe.details) {
236
+ probe.details = {};
237
+ }
238
+ if (options?.siblingControllerOk && probe.details) {
239
+ probe.details.sdkMessage = `${probe.details.sdkMessage ?? "sdk probed"}; sibling controller identity ok`;
240
+ }
241
+ return buildPiReadinessReport(probe);
242
+ }
243
+ catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error);
245
+ return buildPiReadinessReport({
246
+ sdkAvailable: false,
247
+ authReady: false,
248
+ modelsAvailable: false,
249
+ interviewSessionOk: false,
250
+ details: {
251
+ sdkMessage: `Pi readiness discovery error: ${message}`,
252
+ authMessage: "no credential probe completed",
253
+ modelsMessage: "no model list available",
254
+ sessionMessage: "Interview session preflight not run",
255
+ },
256
+ });
257
+ }
84
258
  }
85
259
  export function readinessGateFailure(report, action) {
86
260
  if (!isActionBlockedByReadiness(report.state, action))
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Derive Console task title + canonical taskId from imported PRD markdown.
3
+ * Deterministic (no LLM): first ATX heading, else first meaningful line.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { formatLocalBusinessDate } from "../../task/runtime.js";
7
+ const GENERIC_SLUGS = new Set([
8
+ "test",
9
+ "fix",
10
+ "tmp",
11
+ "new-task",
12
+ "requirement",
13
+ "req",
14
+ "prd",
15
+ "untitled",
16
+ ]);
17
+ function cleanTitleLine(line) {
18
+ return line
19
+ .replace(/^#{1,6}\s+/, "")
20
+ .replace(/^>\s+/, "")
21
+ .replace(/^[*_\s]+|[*_\s]+$/g, "")
22
+ .replace(/\s+/g, " ")
23
+ .trim();
24
+ }
25
+ function isNoiseLine(line) {
26
+ const t = line.trim();
27
+ if (!t)
28
+ return true;
29
+ if (/^---+$/.test(t))
30
+ return true;
31
+ if (/^```/.test(t))
32
+ return true;
33
+ if (/^<!--/.test(t))
34
+ return true;
35
+ return false;
36
+ }
37
+ export function extractPrdTitle(content) {
38
+ const lines = content.split(/\r?\n/);
39
+ for (const raw of lines) {
40
+ const trimmed = raw.trim();
41
+ if (!/^#\s+\S/.test(trimmed))
42
+ continue;
43
+ const title = cleanTitleLine(trimmed);
44
+ if (title)
45
+ return { title: title.slice(0, 120), source: "heading" };
46
+ }
47
+ for (const raw of lines) {
48
+ if (isNoiseLine(raw))
49
+ continue;
50
+ const trimmed = raw.trim();
51
+ if (/^#{2,6}\s+/.test(trimmed))
52
+ continue;
53
+ const title = cleanTitleLine(trimmed);
54
+ if (title.length >= 2) {
55
+ return { title: title.slice(0, 120), source: "first-line" };
56
+ }
57
+ }
58
+ for (const raw of lines) {
59
+ const trimmed = raw.trim();
60
+ if (!/^#{2,6}\s+\S/.test(trimmed))
61
+ continue;
62
+ const title = cleanTitleLine(trimmed);
63
+ if (title)
64
+ return { title: title.slice(0, 120), source: "heading" };
65
+ }
66
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 8);
67
+ return { title: `需求任务 ${hash}`, source: "content-hash" };
68
+ }
69
+ export function asciiSlugFromTitle(title, contentForFallback) {
70
+ const ascii = title
71
+ .normalize("NFKD")
72
+ .replace(/[\u0300-\u036f]/g, "")
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, "-")
75
+ .replace(/^-+|-+$/g, "")
76
+ .replace(/-{2,}/g, "-")
77
+ .slice(0, 48);
78
+ if (ascii.length >= 3 && !GENERIC_SLUGS.has(ascii))
79
+ return ascii;
80
+ const hash = createHash("sha256")
81
+ .update(contentForFallback || title)
82
+ .digest("hex")
83
+ .slice(0, 8);
84
+ return `req-${hash}`;
85
+ }
86
+ export function deriveTaskIdentityFromPrd(content, now = new Date()) {
87
+ const extracted = extractPrdTitle(content);
88
+ const slug = asciiSlugFromTitle(extracted.title, content);
89
+ const date = formatLocalBusinessDate(now);
90
+ return {
91
+ title: extracted.title,
92
+ slug,
93
+ taskId: `${date}-${slug}`,
94
+ source: extracted.source,
95
+ };
96
+ }
97
+ export function nextTaskIdRevision(taskId, revision) {
98
+ if (revision <= 1)
99
+ return taskId;
100
+ const base = taskId.replace(/-r\d+$/, "");
101
+ return `${base}-r${revision}`;
102
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Resolve the newest DAG run id for a harness task under `.harness/dag-runs`.
3
+ * Used by Console observeLink so task-scoped opens land on `#/dag/<runId>`.
4
+ */
5
+ import { readdir, readFile, stat } from "node:fs/promises";
6
+ import path from "node:path";
7
+ const LIFECYCLE_ORDER = ["active", "paused", "completed"];
8
+ async function readTaskIdFromRunDir(runDir) {
9
+ const runJsonPath = path.join(runDir, "run.json");
10
+ try {
11
+ const raw = JSON.parse(await readFile(runJsonPath, "utf8"));
12
+ const fromBinding = raw.taskContractBinding?.taskId?.trim() ||
13
+ raw.sourceBinding?.taskId?.trim();
14
+ if (fromBinding)
15
+ return fromBinding;
16
+ const title = raw.title?.trim() ?? "";
17
+ const hybrid = /^Hybrid DAG:\s*(.+)$/i.exec(title);
18
+ if (hybrid?.[1]?.trim())
19
+ return hybrid[1].trim();
20
+ }
21
+ catch {
22
+ /* missing/invalid run.json — skip */
23
+ }
24
+ return undefined;
25
+ }
26
+ /**
27
+ * Prefer active/paused over completed; within a lifecycle, newest mtime wins.
28
+ */
29
+ export async function resolveLatestDagRunIdForTask(repoRoot, taskId) {
30
+ const wanted = taskId.trim();
31
+ if (!wanted)
32
+ return undefined;
33
+ const matches = [];
34
+ for (const lifecycle of LIFECYCLE_ORDER) {
35
+ const lifecycleDir = path.join(repoRoot, ".harness", "dag-runs", lifecycle);
36
+ let entries = [];
37
+ try {
38
+ entries = await readdir(lifecycleDir);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ for (const name of entries) {
44
+ if (!name || name === ".gitkeep")
45
+ continue;
46
+ const runDir = path.join(lifecycleDir, name);
47
+ let st;
48
+ try {
49
+ st = await stat(runDir);
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ if (!st.isDirectory())
55
+ continue;
56
+ const boundTaskId = await readTaskIdFromRunDir(runDir);
57
+ if (boundTaskId !== wanted)
58
+ continue;
59
+ matches.push({
60
+ dagRunId: name,
61
+ lifecycle,
62
+ mtimeMs: st.mtimeMs,
63
+ });
64
+ }
65
+ }
66
+ if (matches.length === 0)
67
+ return undefined;
68
+ const lifecycleRank = (value) => LIFECYCLE_ORDER.indexOf(value);
69
+ matches.sort((a, b) => {
70
+ const life = lifecycleRank(a.lifecycle) - lifecycleRank(b.lifecycle);
71
+ if (life !== 0)
72
+ return life;
73
+ return b.mtimeMs - a.mtimeMs;
74
+ });
75
+ return matches[0];
76
+ }
@@ -48,6 +48,7 @@ export async function createConsoleServer(options) {
48
48
  const readiness = await probePiReadiness({
49
49
  override: options.piReadinessProbe,
50
50
  });
51
+ let readinessCache = readiness;
51
52
  const artifactRoot = path.join(appData.root, "client-artifacts", appData.fingerprint);
52
53
  const client = options.createClient?.(artifactRoot) ??
53
54
  new LoopAgentClient({
@@ -58,7 +59,14 @@ export async function createConsoleServer(options) {
58
59
  if (!options.skipReconcile) {
59
60
  await operations.reconcileOnBoot();
60
61
  }
61
- const getReadiness = () => readiness;
62
+ const getReadiness = () => readinessCache;
63
+ const refreshReadiness = async () => {
64
+ readinessCache = await probePiReadiness({
65
+ override: options.piReadinessProbe,
66
+ });
67
+ actionContext.readiness = readinessCache;
68
+ return readinessCache;
69
+ };
62
70
  const actionContext = {
63
71
  repoRoot,
64
72
  appData,
@@ -70,8 +78,9 @@ export async function createConsoleServer(options) {
70
78
  confirmations,
71
79
  interviews,
72
80
  assessments,
73
- readiness,
81
+ readiness: readinessCache,
74
82
  getReadiness,
83
+ refreshReadiness,
75
84
  observeBaseUrl: options.observeBaseUrl,
76
85
  fetchImpl: options.fetchImpl,
77
86
  };