@pasko70/pibo 1.9.11 → 1.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 (53) hide show
  1. package/dist/apps/chat/agent-profiles.js +2 -2
  2. package/dist/apps/chat/agent-store.js +16 -3
  3. package/dist/apps/chat/chat-request-normalizers.js +10 -0
  4. package/dist/apps/chat/data/timeline-query-service.js +11 -0
  5. package/dist/apps/chat/loop-api.js +176 -0
  6. package/dist/apps/chat/trace.js +2 -0
  7. package/dist/apps/chat/web-app.js +13 -6
  8. package/dist/apps/chat/workflow-manual-trigger-runtime.js +149 -46
  9. package/dist/apps/chat-ui/assets/{dist-yCYNNb5d.js → dist-BwKObYnX.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-BCB6zezO.js → dist-CKtT8YGm.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-CnVsqwSG.js → dist-CS7wdk0Z.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-HqTN67dc.js → dist-D-cxLQO1.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{dist-BNMu92bb.js → dist-DUlaXAk7.js} +1 -1
  14. package/dist/apps/chat-ui/assets/{dist-CzE6k3F3.js → dist-DlATLa-U.js} +1 -1
  15. package/dist/apps/chat-ui/assets/{dist-Dq4GxJi3.js → dist-GdEM8UW1.js} +1 -1
  16. package/dist/apps/chat-ui/assets/{dist-BZ2eTC4f.js → dist-LHRs1Nhr.js} +1 -1
  17. package/dist/apps/chat-ui/assets/{dist-D811wJeV.js → dist-Y-AA2omI.js} +1 -1
  18. package/dist/apps/chat-ui/assets/{dist-WyXdYl-w.js → dist-nOLTkZrJ.js} +1 -1
  19. package/dist/apps/chat-ui/assets/{dist-BHa-kcGl.js → dist-wE9nop9V.js} +1 -1
  20. package/dist/apps/chat-ui/assets/{index-DwHJfmiF.js → index-8W_yMHQI.js} +6 -6
  21. package/dist/apps/chat-ui/index.html +1 -1
  22. package/dist/cli.js +23 -6
  23. package/dist/core/routed-session.js +30 -1
  24. package/dist/core/runtime.js +6 -1
  25. package/dist/core/session-router.js +4 -2
  26. package/dist/data/ingest-service.js +7 -0
  27. package/dist/data/message-store.js +11 -0
  28. package/dist/data/schema.js +2 -0
  29. package/dist/gateway/server.js +4 -2
  30. package/dist/gateway/web.js +2 -2
  31. package/dist/loops/channel.js +8 -0
  32. package/dist/loops/cli.js +208 -0
  33. package/dist/loops/plugin.js +16 -0
  34. package/dist/loops/prompts.js +83 -0
  35. package/dist/loops/service.js +357 -0
  36. package/dist/loops/stopping.js +170 -0
  37. package/dist/loops/store.js +531 -0
  38. package/dist/loops/templates.js +232 -0
  39. package/dist/loops/tools.js +167 -0
  40. package/dist/loops/types.js +1 -0
  41. package/dist/plugins/builtin.js +8 -0
  42. package/dist/plugins/registry.js +23 -12
  43. package/dist/resources/lifecycle.js +4 -6
  44. package/dist/resources/reaper-state.js +30 -3
  45. package/dist/resources/reaper.js +43 -15
  46. package/dist/shared/trace-engine.js +3 -2
  47. package/dist/shared/trace-event-projection.js +26 -0
  48. package/dist/tools/guides.js +51 -0
  49. package/dist/tools/index.js +23 -0
  50. package/dist/tools/registry.js +21 -3
  51. package/package.json +5 -2
  52. package/skills/builtin/loop/SKILL.md +69 -0
  53. package/skills/builtin/ralph-loop/SKILL.md +4 -2
@@ -0,0 +1,232 @@
1
+ const promiseCompletePolicy = {
2
+ mode: 'any',
3
+ conditions: [{ id: 'promise-complete', type: 'pibo.loop.promise-complete' }],
4
+ };
5
+ const goalStatusPolicy = {
6
+ mode: 'any',
7
+ conditions: [{ id: 'goal-status', type: 'pibo.loop.goal-status' }],
8
+ };
9
+ const singleRunPolicy = {
10
+ mode: 'any',
11
+ conditions: [{ id: 'max-iterations', type: 'pibo.loop.max-iterations' }],
12
+ };
13
+ function markdown(strings) {
14
+ return String.raw(strings).replaceAll('\\`', '`');
15
+ }
16
+ export const BUILT_IN_LOOP_JOB_TEMPLATES = [
17
+ {
18
+ id: 'goal-objective',
19
+ name: 'Goal objective',
20
+ description: 'Default same-session goal loop: keep pursuing the full objective across turns until completion is proven or another stop condition is satisfied.',
21
+ category: 'general',
22
+ job: {
23
+ mode: 'goal',
24
+ name: 'Goal loop',
25
+ description: 'Long-running objective continued in one Pibo Session.',
26
+ prompt: markdown `# Objective
27
+
28
+ <Describe the complete requested end state.>
29
+
30
+ # Success criteria
31
+
32
+ - <Observable completion criterion>
33
+ - <Required verification>
34
+ - <Behavior that must not regress>
35
+
36
+ # Constraints
37
+
38
+ - <Scope, safety, or approval boundary>`,
39
+ stopPolicy: goalStatusPolicy,
40
+ },
41
+ },
42
+ {
43
+ id: 'prd-single-story-standard',
44
+ name: 'PRD single story standard',
45
+ description: 'Legacy Ralph loop: pick the highest-priority failing PRD user story, implement exactly one story, test, commit, update progress, then stop or continue on the next run.',
46
+ category: 'prd',
47
+ job: {
48
+ mode: 'ralph',
49
+ name: 'Ralph PRD single story',
50
+ description: 'One PRD user story per Loop iteration.',
51
+ prompt: markdown `# Ralph Auftrag: <Project / Change Name>
52
+
53
+ ## Worktree
54
+
55
+ Work from the host worktree, not from a container copy:
56
+
57
+ \`\`\`bash
58
+ cd <host-worktree>
59
+ \`\`\`
60
+
61
+ Branch: \`<branch-name>\`
62
+ Base: \`<base-branch-or-commit>\`
63
+ PRDs: \`<path-to-prds>/prd_*.json\`
64
+ Progress log: \`<path-to-prds>/progress.txt\`
65
+
66
+ If a container is required for build, gateway, or browser checks, use it only for running commands. Edit files and run git commands in the host worktree. Do not edit \`/app\` inside a container.
67
+
68
+ ## Ralph Agent Instructions
69
+
70
+ You are an autonomous coding agent working on a software project.
71
+
72
+ ### Your Task
73
+
74
+ 1. Work from the host worktree above.
75
+ 2. Read all PRD JSON files matching \`<path-to-prds>/prd_*.json\`.
76
+ 3. Read the progress log. If it does not exist, create it.
77
+ 4. Read the \`## Codebase Patterns\` section at the top of the progress log if present.
78
+ 5. Pick the highest-priority user story where \`passes: false\`.
79
+ 6. Implement that single user story only.
80
+ 7. Run quality checks:
81
+ - Always run \`npm run typecheck\`.
82
+ - Run relevant tests for touched areas.
83
+ - For UI stories, run browser verification in the configured dev container/browser environment.
84
+ 8. If checks pass, update the PRD JSON to set \`passes: true\` for the completed story.
85
+ 9. Commit all changes from the host worktree with message:
86
+ \`feat: [Story ID] - [Story Title]\`
87
+ 10. Append your progress to the progress log.
88
+
89
+ ### Progress Report Format
90
+
91
+ Append to the progress log. Never replace; always append.
92
+
93
+ \`\`\`md
94
+ ## [Date/Time] - [Story ID]
95
+ - What was implemented
96
+ - Files changed
97
+ - Quality checks run
98
+ - Browser verification, if UI changed
99
+ - **Learnings for future iterations:**
100
+ - Patterns discovered
101
+ - Gotchas encountered
102
+ - Useful context
103
+ ---
104
+ \`\`\`
105
+
106
+ ### Consolidate Patterns
107
+
108
+ If you discover a reusable pattern, add it to the \`## Codebase Patterns\` section at the top of the progress log. Only add general reusable patterns, not story-specific notes.
109
+
110
+ ## Quality Requirements
111
+
112
+ - All commits must pass quality checks.
113
+ - Do not commit broken code.
114
+ - Keep changes focused and minimal.
115
+ - Follow existing code patterns.
116
+ - For UI stories, browser verification is mandatory.
117
+
118
+ ## Stop Condition
119
+
120
+ After completing one user story, check whether all stories in all PRD JSON files have \`passes: true\`.
121
+
122
+ If all stories are complete and passing, reply with the XML completion marker on its own line. Compose it from the opening tag \`<promise>\`, the word \`COMPLETE\`, and the closing tag \`</promise>\`.
123
+
124
+ Do not quote, negate, explain, or mention that literal marker unless all stories are complete and you intend to stop the job. If any story remains with \`passes: false\`, end normally so another iteration can pick up the next story and say only that the completion marker was omitted.`,
125
+ stopPolicy: promiseCompletePolicy,
126
+ },
127
+ },
128
+ {
129
+ id: 'prd-batch-stories',
130
+ name: 'PRD batch stories',
131
+ description: 'Batch loop: implement several failing PRD user stories in priority order in one Loop run, committing after each completed story.',
132
+ category: 'prd',
133
+ job: {
134
+ mode: 'ralph',
135
+ name: 'Ralph PRD batch',
136
+ description: 'Multiple PRD user stories per Loop run.',
137
+ prompt: markdown `# Ralph Auftrag: <Project / Change Name> Batch
138
+
139
+ ## Worktree
140
+
141
+ Work from the host worktree:
142
+
143
+ \`\`\`bash
144
+ cd <host-worktree>
145
+ \`\`\`
146
+
147
+ PRDs: \`<path-to-prds>/prd_*.json\`
148
+ Progress log: \`<path-to-prds>/progress.txt\`
149
+ Batch limit: \`<max-stories-this-run>\`
150
+
151
+ ## Task
152
+
153
+ 1. Read the PRD JSON files and progress log.
154
+ 2. Work through failing user stories in priority order.
155
+ 3. Complete up to \`<max-stories-this-run>\` stories in this run.
156
+ 4. Keep each story isolated:
157
+ - implement one story;
158
+ - run relevant checks;
159
+ - set that story's \`passes\` to \`true\` only after checks pass;
160
+ - commit with \`feat: [Story ID] - [Story Title]\`;
161
+ - append a progress-log entry.
162
+ 5. Stop early if a story is blocked, ambiguous, or tests fail. Do not continue with later stories after a failed story.
163
+
164
+ ## Quality Requirements
165
+
166
+ - Always run \`npm run typecheck\` before each story commit unless the repository has a documented narrower check for this batch.
167
+ - Run relevant tests for touched areas.
168
+ - Browser verification is mandatory for UI changes.
169
+ - Keep changes focused; do not bundle unrelated refactors.
170
+
171
+ ## Stop Condition
172
+
173
+ If all stories in all PRD JSON files are complete and passing, reply with the XML completion marker on its own line. Compose it from the opening tag \`<promise>\`, the word \`COMPLETE\`, and the closing tag \`</promise>\`.
174
+
175
+ Do not quote, negate, explain, or mention that literal marker unless all stories are complete and you intend to stop the job. Otherwise report the stories completed and the next remaining failing story, and say only that the completion marker was omitted.`,
176
+ stopPolicy: promiseCompletePolicy,
177
+ },
178
+ },
179
+ {
180
+ id: 'single-run-objective',
181
+ name: 'Single-run objective',
182
+ description: 'Non-PRD template for one focused objective with explicit done criteria. Stops after one completed run attempt by max-iterations.',
183
+ category: 'general',
184
+ job: {
185
+ mode: 'ralph',
186
+ name: 'Ralph single-run objective',
187
+ description: 'One focused non-PRD objective.',
188
+ maxIterations: 1,
189
+ stopPolicy: singleRunPolicy,
190
+ prompt: markdown `# Ralph Auftrag: <Objective>
191
+
192
+ ## Worktree
193
+
194
+ \`\`\`bash
195
+ cd <host-worktree>
196
+ \`\`\`
197
+
198
+ Branch: \`<branch-name>\`
199
+
200
+ ## Objective
201
+
202
+ <Describe the concrete outcome.>
203
+
204
+ ## Done Criteria
205
+
206
+ - <Pass/fail criterion 1>
207
+ - <Pass/fail criterion 2>
208
+ - <Required verification command or manual check>
209
+
210
+ ## Instructions
211
+
212
+ 1. Inspect the relevant code and docs before editing.
213
+ 2. State assumptions in code comments or docs only when they matter for maintainers.
214
+ 3. Implement the smallest focused change that satisfies the done criteria.
215
+ 4. Run \`npm run typecheck\` and relevant tests.
216
+ 5. Commit with a concise message if checks pass.
217
+ 6. End with a short report listing files changed and verification run.
218
+
219
+ This is not a PRD loop. Do not edit PRD JSON progress unless the objective explicitly asks for it.`,
220
+ },
221
+ },
222
+ ];
223
+ export function listLoopJobTemplates() {
224
+ return BUILT_IN_LOOP_JOB_TEMPLATES.map((template) => cloneTemplate(template));
225
+ }
226
+ export function getLoopJobTemplate(id) {
227
+ const template = BUILT_IN_LOOP_JOB_TEMPLATES.find((item) => item.id === id);
228
+ return template ? cloneTemplate(template) : undefined;
229
+ }
230
+ function cloneTemplate(template) {
231
+ return JSON.parse(JSON.stringify(template));
232
+ }
@@ -0,0 +1,167 @@
1
+ import { StringEnum, Type } from '@earendil-works/pi-ai';
2
+ import { defineTool } from '@earendil-works/pi-coding-agent';
3
+ import { createDefaultPiboLoopStore } from './store.js';
4
+ export const PIBO_GOAL_TOOL_NAMES = ['get_goal', 'create_goal', 'update_goal'];
5
+ let configuredStorePath;
6
+ export function configurePiboGoalToolStorePath(path) {
7
+ configuredStorePath = path;
8
+ }
9
+ function toolResult(value, isError = false) {
10
+ return {
11
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
12
+ details: value,
13
+ ...(isError ? { isError: true } : {}),
14
+ };
15
+ }
16
+ function errorResult(error) {
17
+ return toolResult({ ok: false, error: error instanceof Error ? error.message : String(error) }, true);
18
+ }
19
+ function requireSessionContext(context) {
20
+ const piboSessionId = context.piboSessionId?.trim();
21
+ if (!piboSessionId)
22
+ throw new Error('Goal tools require the current Pibo Session ID');
23
+ return {
24
+ piboSessionId,
25
+ piboRoomId: context.piboRoomId?.trim() || undefined,
26
+ profileName: context.profileName?.trim() || 'base',
27
+ };
28
+ }
29
+ function positiveInteger(value, field) {
30
+ if (value === undefined)
31
+ return undefined;
32
+ if (!Number.isInteger(value) || value < 1)
33
+ throw new Error(`${field} must be a positive integer`);
34
+ return value;
35
+ }
36
+ function goalPayload(job) {
37
+ const tokensUsed = job.state.tokensUsed ?? 0;
38
+ const tokenBudget = job.tokenBudget;
39
+ return {
40
+ goalId: job.id,
41
+ objective: job.prompt,
42
+ status: effectiveGoalStatus(job),
43
+ tokenBudget: tokenBudget ?? null,
44
+ tokensUsed,
45
+ remainingTokens: tokenBudget === undefined ? null : Math.max(0, tokenBudget - tokensUsed),
46
+ timeUsedSeconds: job.state.timeUsedSeconds ?? 0,
47
+ };
48
+ }
49
+ function effectiveGoalStatus(job) {
50
+ return job.state.goalStatus ?? (job.enabled ? 'active' : 'paused');
51
+ }
52
+ async function withStore(options, action) {
53
+ if (options.store)
54
+ return await action(options.store);
55
+ const store = createDefaultPiboLoopStore({ path: configuredStorePath });
56
+ try {
57
+ return await action(store);
58
+ }
59
+ finally {
60
+ store.close();
61
+ }
62
+ }
63
+ function createGetGoalTool(context, options) {
64
+ return defineTool({
65
+ name: 'get_goal',
66
+ label: 'Get Goal',
67
+ description: 'Get the current goal for this Pibo Session, including status, token budget, consumed tokens, remaining tokens, and elapsed active time.',
68
+ promptSnippet: 'Use get_goal when you need the authoritative persisted status or accounting for the current Pibo Session goal.',
69
+ parameters: Type.Object({}),
70
+ async execute() {
71
+ try {
72
+ const { piboSessionId } = requireSessionContext(context);
73
+ return await withStore(options, (store) => {
74
+ const job = store.getLatestGoalForSession(piboSessionId);
75
+ return toolResult({ ok: true, goal: job ? goalPayload(job) : null });
76
+ });
77
+ }
78
+ catch (error) {
79
+ return errorResult(error);
80
+ }
81
+ },
82
+ });
83
+ }
84
+ function createCreateGoalTool(context, options) {
85
+ return defineTool({
86
+ name: 'create_goal',
87
+ label: 'Create Goal',
88
+ description: 'Create an active persisted Goal Loop for this Pibo Session only when explicitly requested. Fails while this session already has an unfinished goal.',
89
+ promptSnippet: 'Call create_goal only when the user or system explicitly requests a persistent goal. Do not infer a goal from an ordinary task.',
90
+ parameters: Type.Object({
91
+ objective: Type.String({ description: 'Concrete objective to pursue across automatic continuations.' }),
92
+ token_budget: Type.Optional(Type.Number({ description: 'Optional positive token budget. Omit unless explicitly requested.' })),
93
+ }),
94
+ async execute(_toolCallId, params) {
95
+ try {
96
+ const session = requireSessionContext(context);
97
+ const objective = params.objective?.trim();
98
+ if (!objective)
99
+ throw new Error('objective is required');
100
+ const tokenBudget = positiveInteger(params.token_budget, 'token_budget');
101
+ return await withStore(options, (store) => {
102
+ const existing = store.getLatestGoalForSession(session.piboSessionId);
103
+ if (existing && effectiveGoalStatus(existing) !== 'complete') {
104
+ throw new Error('cannot create a new goal because this Pibo Session has an unfinished goal; complete the existing goal first');
105
+ }
106
+ const job = store.createJob({
107
+ mode: 'goal',
108
+ enabled: true,
109
+ target: session.piboRoomId ? { kind: 'room', roomId: session.piboRoomId } : { kind: 'default-chat' },
110
+ profile: session.profileName,
111
+ prompt: objective,
112
+ tokenBudget,
113
+ initialPiboSessionId: session.piboSessionId,
114
+ });
115
+ return toolResult({ ok: true, goal: goalPayload(job) });
116
+ });
117
+ }
118
+ catch (error) {
119
+ return errorResult(error);
120
+ }
121
+ },
122
+ });
123
+ }
124
+ function createUpdateGoalTool(context, options) {
125
+ return defineTool({
126
+ name: 'update_goal',
127
+ label: 'Update Goal',
128
+ description: 'Mark the current goal complete or genuinely blocked. Complete requires verified achievement. Blocked requires the same impasse for at least three consecutive goal turns.',
129
+ promptSnippet: 'Use update_goal only with status complete after a requirement-by-requirement completion audit, or blocked after the strict repeated-blocker audit.',
130
+ parameters: Type.Object({
131
+ status: StringEnum(['complete', 'blocked'], { description: 'Terminal status for the current goal.' }),
132
+ }),
133
+ async execute(_toolCallId, params) {
134
+ try {
135
+ const { piboSessionId } = requireSessionContext(context);
136
+ if (params.status !== 'complete' && params.status !== 'blocked')
137
+ throw new Error('status must be complete or blocked');
138
+ const status = params.status;
139
+ return await withStore(options, (store) => {
140
+ const existing = store.getLatestGoalForSession(piboSessionId);
141
+ if (!existing)
142
+ throw new Error('cannot update goal because this Pibo Session has no goal');
143
+ const job = store.updateGoalStatus(existing.id, status);
144
+ if (!job)
145
+ throw new Error('goal no longer exists');
146
+ return toolResult({
147
+ ok: true,
148
+ goal: goalPayload(job),
149
+ ...(status === 'complete' && job.tokenBudget !== undefined
150
+ ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported tokens consumed before the current model turn finishes` }
151
+ : {}),
152
+ });
153
+ });
154
+ }
155
+ catch (error) {
156
+ return errorResult(error);
157
+ }
158
+ },
159
+ });
160
+ }
161
+ export function createPiboGoalToolDefinitions(context, options = {}) {
162
+ return [
163
+ createGetGoalTool(context, options),
164
+ createCreateGoalTool(context, options),
165
+ createUpdateGoalTool(context, options),
166
+ ];
167
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -112,6 +112,7 @@ function requireLogoutParams(event) {
112
112
  }
113
113
  function createBaseProfileBuilder(profileName, context) {
114
114
  return addPiboNativeToolingContext(new InitialSessionContextBuilder(profileName)
115
+ .withToolPackages({ goalControl: true })
115
116
  .addSkill(context.getSkill("pi-agent-harness")), context);
116
117
  }
117
118
  export const piboCorePlugin = definePiboPlugin({
@@ -148,6 +149,11 @@ export const piboCorePlugin = definePiboPlugin({
148
149
  path: builtinSkillPath("skill-creator"),
149
150
  kind: "builtin",
150
151
  });
152
+ api.registerSkill({
153
+ name: "loop",
154
+ path: builtinSkillPath("loop"),
155
+ kind: "builtin",
156
+ });
151
157
  api.registerSkill({
152
158
  name: "ralph-loop",
153
159
  path: builtinSkillPath("ralph-loop"),
@@ -167,6 +173,7 @@ export const piboCorePlugin = definePiboPlugin({
167
173
  create() {
168
174
  return new InitialSessionContextBuilder(DEFAULT_PIBO_PROFILE_NAME)
169
175
  .withBuiltinToolNames(["read", "bash", "edit", "write"])
176
+ .withToolPackages({ goalControl: true })
170
177
  .createSession();
171
178
  },
172
179
  });
@@ -451,6 +458,7 @@ export function selectDefaultPiboProfileName(registry) {
451
458
  export function createDefaultPiboProfile() {
452
459
  return new InitialSessionContextBuilder(DEFAULT_PIBO_PROFILE_NAME)
453
460
  .withBuiltinToolNames(["read", "bash", "edit", "write"])
461
+ .withToolPackages({ goalControl: true })
454
462
  .createSession();
455
463
  }
456
464
  export function resolvePiboProfileNameFromRegistryOrDefault(registry, profileName) {
@@ -33,7 +33,7 @@ export class PiboPluginRegistry {
33
33
  capabilityPackages = new Map();
34
34
  eventListeners = new Set();
35
35
  productEventListeners = new Set();
36
- ralphStopConditions = new Map();
36
+ loopStopConditions = new Map();
37
37
  pluginIds = new Set();
38
38
  pluginNames = new Map();
39
39
  eventErrors = [];
@@ -133,20 +133,22 @@ export class PiboPluginRegistry {
133
133
  registerCapabilityPackage(pkg) {
134
134
  this.addUnique(this.capabilityPackages, pkg.name, { ...pkg, toolNames: [...pkg.toolNames] }, "capability package");
135
135
  }
136
- registerRalphStopCondition(condition, pluginId) {
136
+ registerLoopStopCondition(condition, pluginId) {
137
137
  if (!condition.type.trim())
138
- throw new Error('Ralph stop condition type is required');
138
+ throw new Error('Loop stop condition type is required');
139
139
  if (!condition.name.trim())
140
- throw new Error(`Ralph stop condition "${condition.type}" name is required`);
140
+ throw new Error(`Loop stop condition "${condition.type}" name is required`);
141
141
  if (condition.phases.length === 0)
142
- throw new Error(`Ralph stop condition "${condition.type}" must support at least one phase`);
143
- this.addUnique(this.ralphStopConditions, condition.type, { definition: { ...condition, phases: [...condition.phases] }, pluginId }, 'Ralph stop condition');
142
+ throw new Error(`Loop stop condition "${condition.type}" must support at least one phase`);
143
+ this.addUnique(this.loopStopConditions, condition.type, { definition: { ...condition, phases: [...condition.phases] }, pluginId }, 'Loop stop condition');
144
144
  }
145
- getRalphStopConditionDefinitions() {
146
- return [...this.ralphStopConditions.values()].map((entry) => entry.definition);
145
+ registerRalphStopCondition(condition, pluginId) { this.registerLoopStopCondition(condition, pluginId); }
146
+ getLoopStopConditionDefinitions() {
147
+ return [...this.loopStopConditions.values()].map((entry) => entry.definition);
147
148
  }
148
- getRalphStopConditionInfos() {
149
- return [...this.ralphStopConditions.values()].map((entry) => ({
149
+ getRalphStopConditionDefinitions() { return this.getLoopStopConditionDefinitions(); }
150
+ getLoopStopConditionInfos() {
151
+ return [...this.loopStopConditions.values()].map((entry) => ({
150
152
  type: entry.definition.type,
151
153
  name: entry.definition.name,
152
154
  description: entry.definition.description,
@@ -157,6 +159,7 @@ export class PiboPluginRegistry {
157
159
  pluginName: entry.pluginId ? this.pluginNames.get(entry.pluginId) : undefined,
158
160
  }));
159
161
  }
162
+ getRalphStopConditionInfos() { return this.getLoopStopConditionInfos(); }
160
163
  onEvent(listener) {
161
164
  this.eventListeners.add(listener);
162
165
  }
@@ -203,6 +206,7 @@ export class PiboPluginRegistry {
203
206
  builtinToolNames: [...sessionContext.builtinToolNames],
204
207
  autoContextFiles: sessionContext.autoContextFiles,
205
208
  runControl: sessionContext.toolPackages.runControl === true,
209
+ goalControl: sessionContext.toolPackages.goalControl !== false,
206
210
  };
207
211
  });
208
212
  }
@@ -255,6 +259,11 @@ export class PiboPluginRegistry {
255
259
  "pibo_run_ack",
256
260
  ],
257
261
  },
262
+ {
263
+ name: "pibo-goal-control",
264
+ description: "Expose get_goal, create_goal, and update_goal as one native goal lifecycle package.",
265
+ toolNames: ["get_goal", "create_goal", "update_goal"],
266
+ },
258
267
  ...[...this.capabilityPackages.values()].map((pkg) => ({
259
268
  ...pkg,
260
269
  toolNames: [...pkg.toolNames],
@@ -264,7 +273,8 @@ export class PiboPluginRegistry {
264
273
  piboTools: listInstalledCliToolAgentContexts(),
265
274
  mcpServers: [],
266
275
  piPackages: listPiPackages(),
267
- ralphStopConditions: this.getRalphStopConditionInfos(),
276
+ loopStopConditions: this.getLoopStopConditionInfos(),
277
+ ralphStopConditions: this.getLoopStopConditionInfos(),
268
278
  };
269
279
  }
270
280
  resolveProfileName(name) {
@@ -355,7 +365,8 @@ export class PiboPluginRegistry {
355
365
  registerAuthService: (service) => this.registerAuthService(service),
356
366
  registerWebApp: (app) => this.registerWebApp(app),
357
367
  registerCapabilityPackage: (pkg) => this.registerCapabilityPackage(withPluginPackageContext(pkg)),
358
- registerRalphStopCondition: (condition) => this.registerRalphStopCondition(condition, pluginId),
368
+ registerLoopStopCondition: (condition) => this.registerLoopStopCondition(condition, pluginId),
369
+ registerRalphStopCondition: (condition) => this.registerLoopStopCondition(condition, pluginId),
359
370
  onEvent: (listener) => this.onEvent(listener),
360
371
  emitProductEvent: (event) => this.emitProductEvent(event),
361
372
  onProductEvent: (listener) => this.onProductEvent(listener),
@@ -243,15 +243,13 @@ function buildBrowserReapPlanItem(record, now, idleTimeoutMinutes) {
243
243
  preservesWorktree: true,
244
244
  };
245
245
  }
246
- async function planComputeReapSafely(options) {
246
+ export async function planComputeReapSafely(options, planCompute = planReapWorkers) {
247
247
  try {
248
- return await planReapWorkers(options);
248
+ return await planCompute(options);
249
249
  }
250
- catch (error) {
251
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
252
- throw error;
250
+ catch {
253
251
  const plan = buildComputeWorkerReapPlan([], options);
254
- plan.nextCommands = ["Docker CLI is unavailable in this runtime; browser and stale-file cleanup remain active."];
252
+ plan.nextCommands = ["Docker compute cleanup is unavailable; browser and stale-file cleanup remain active."];
255
253
  return plan;
256
254
  }
257
255
  }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { dirname } from "node:path";
@@ -5,11 +6,37 @@ import { piboHomePath } from "../core/pibo-home.js";
5
6
  export function defaultResourceReaperStatePath() {
6
7
  return process.env.PIBO_RESOURCE_REAPER_STATE_PATH || piboHomePath("resource-reaper-state.json");
7
8
  }
8
- export async function writeResourceReaperState(path, state) {
9
+ const DEFAULT_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200];
10
+ const TRANSIENT_RENAME_ERROR_CODES = new Set(["EACCES", "EBUSY", "ENOTEMPTY", "EPERM"]);
11
+ export async function writeResourceReaperState(path, state, options = {}) {
9
12
  await mkdir(dirname(path), { recursive: true });
10
- const temporaryPath = `${path}.${process.pid}.tmp`;
13
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
14
+ const renameFile = options.rename ?? rename;
15
+ const wait = options.wait ?? defaultWait;
16
+ const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RENAME_RETRY_DELAYS_MS;
11
17
  await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
12
- await rename(temporaryPath, path);
18
+ try {
19
+ for (let attempt = 0;; attempt += 1) {
20
+ try {
21
+ await renameFile(temporaryPath, path);
22
+ return;
23
+ }
24
+ catch (error) {
25
+ if (!isTransientRenameError(error) || attempt >= retryDelaysMs.length)
26
+ throw error;
27
+ await wait(retryDelaysMs[attempt]);
28
+ }
29
+ }
30
+ }
31
+ finally {
32
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
33
+ }
34
+ }
35
+ function isTransientRenameError(error) {
36
+ return error instanceof Error && "code" in error && TRANSIENT_RENAME_ERROR_CODES.has(String(error.code));
37
+ }
38
+ async function defaultWait(delayMs) {
39
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
13
40
  }
14
41
  export async function claimResourceReaperOwnership(lockPath, pid = process.pid, isPidAlive = defaultIsPidAlive) {
15
42
  await mkdir(dirname(lockPath), { recursive: true });