@jossuealcala/madre 0.3.1 → 0.3.3

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.
package/src/room.mjs CHANGED
@@ -21,11 +21,13 @@ import { parseDirectives } from './directives.mjs';
21
21
  import { isValidModelName } from './models.mjs';
22
22
  import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
23
23
  import { createLease, diffSnapshots, snapshot } from './lease.mjs';
24
+ import { stat as statFile } from 'node:fs/promises';
25
+ import { basename as baseName, join as joinPath } from 'node:path';
26
+ import { contentTypeFor } from './files.mjs';
24
27
  import { imageStudioFor } from './image-studio.mjs';
25
28
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
26
29
  import { resolveReferences } from './files.mjs';
27
30
  import { compressAshCode } from './ashcode.mjs';
28
- import { isGitRepo } from './checkpoint.mjs';
29
31
 
30
32
  // Adapters can fail with multi-line stderr or stack traces. The room keeps only
31
33
  // the first meaningful line, bounded, so the event log and the UI stay readable.
@@ -81,6 +83,7 @@ export class Room {
81
83
  #scopeConfig = {};
82
84
  #delegation;
83
85
  #privacy = null;
86
+ #toolsForTurn = null;
84
87
  #maxPlanSteps;
85
88
  #maxConcurrentTurns;
86
89
  #planMaxAgeMs;
@@ -98,6 +101,7 @@ export class Room {
98
101
  memoryServer = null,
99
102
  mother = null,
100
103
  privacy = null,
104
+ toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
101
105
  historicalEvents = [],
102
106
  invokers = defaultInvokers,
103
107
  agentTimeouts = {},
@@ -117,6 +121,7 @@ export class Room {
117
121
  this.#memoryServer = memoryServer;
118
122
  this.#mother = mother;
119
123
  this.#privacy = privacy;
124
+ this.#toolsForTurn = toolsForTurn;
120
125
  this.#invokers = invokers;
121
126
  this.#agentTimeouts = agentTimeouts;
122
127
  this.#maxMessageChars = maxMessageChars;
@@ -193,8 +198,7 @@ export class Room {
193
198
  }
194
199
  const scopes = this.scopesFor(step.agent);
195
200
  const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
196
- const lease = await createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
197
- lease.messageId = parentMessageId;
201
+ const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
198
202
  lease.scopes = decision.decision === 'plan' ? Object.fromEntries(SCOPES.map((scope) => [scope, true])) : enabled;
199
203
  await this.#emit('lease.granted', {
200
204
  escalated: decision.decision,
@@ -202,6 +206,7 @@ export class Room {
202
206
  messageId: parentMessageId,
203
207
  agent: step.agent,
204
208
  outDir: lease.relativeDir,
209
+ scratchDir: lease.scratchDir,
205
210
  scopes: SCOPES.filter((scope) => enabled[scope]),
206
211
  unavailable: SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
207
212
  planId,
@@ -354,11 +359,12 @@ export class Room {
354
359
  const wanted = normalizeMode(mode, create === true ? 2 : 1);
355
360
  if (!parsed.target) return { ok: true, mode: wanted };
356
361
  const scopes = this.scopesFor(parsed.target);
357
- if (wanted === 3) {
358
- if (scopes.maxMode < 3) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #3 in CONNECTIONS first.` };
359
- if (!(await isGitRepo(this.#projectRoot))) return { ok: false, status: 412, mode: wanted, maxMode: scopes.maxMode, error: 'CONTROL needs the project to be a git repository: the checkpoint that makes UNDO possible is a git commit. Run git init first.' };
360
- if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds CONTROL of this project; one holder at a time. STOPALL revokes it.` };
361
- return { ok: true, mode: 3, maxMode: scopes.maxMode };
362
+ if (wanted >= 3) {
363
+ // #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
364
+ // A project without git still checkpoints, in MADRE's shadow repository.
365
+ if (scopes.maxMode < wanted) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #${wanted} in CONNECTIONS to arm ${MODES[wanted].label}.` };
366
+ if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; one holder at a time. STOPALL revokes it.` };
367
+ return { ok: true, mode: wanted, maxMode: scopes.maxMode };
362
368
  }
363
369
  // #2 above the agent's ceiling still goes out: the room answers read-only
364
370
  // and says why (lease.refused), the way CREATE has always behaved.
@@ -517,7 +523,7 @@ export class Room {
517
523
  if (!gate.ok) throw new Error(gate.error);
518
524
  create = requestedMode === 2;
519
525
  const ghost = requestedMode === 0;
520
- const control = requestedMode === 3;
526
+ const control = requestedMode >= 3;
521
527
  const files = (Array.isArray(attachments) ? attachments : []).map((id) => this.attachment(id)).filter(Boolean);
522
528
  if (!parsed.text && !files.length) throw new Error('Write a message first.');
523
529
  if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
@@ -536,7 +542,7 @@ export class Room {
536
542
  if (ghost) this.#ghost.add(messageId);
537
543
  await this.#emit('message.created', {
538
544
  messageId,
539
- mode: ghost ? 0 : control ? 3 : (standing || create ? 2 : 1),
545
+ mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
540
546
  role: 'user',
541
547
  sender: 'you',
542
548
  target: parsed.target,
@@ -574,10 +580,9 @@ export class Room {
574
580
  message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailable.length ? `; it cannot ${unavailable.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
575
581
  });
576
582
  } else {
577
- lease = await createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
578
- lease.messageId = messageId;
583
+ lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
579
584
  // CREATE authorizes the plan to use each delegate's enabled creation
580
- // scopes; a standing write lease authorizes files only.
585
+ // scopes; a default-#2 lease authorizes files only.
581
586
  lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
582
587
  lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
583
588
  await this.#emit('lease.granted', {
@@ -586,6 +591,7 @@ export class Room {
586
591
  messageId,
587
592
  agent: parsed.target,
588
593
  outDir: lease.relativeDir,
594
+ scratchDir: lease.scratchDir,
589
595
  grantedBy: 'you',
590
596
  scopes: standing ? ['write'] : enabled,
591
597
  unavailable,
@@ -606,7 +612,26 @@ export class Room {
606
612
  allowDelegation = false;
607
613
  await this.#alert('plan-active', `A plan by @${[...this.#plans.values()][0].orchestrator} is still running. Your message will be answered, but no second plan will start. Type STOPALL to halt every agent.`, `plan-active:${messageId}`);
608
614
  }
609
- await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? 3 : (lease ? 2 : 1) }));
615
+ await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
616
+ }
617
+
618
+ // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
619
+ // under .pulse/out/ for what has no natural place. Each turn under it takes a checkpoint
620
+ // and puts back whatever existed before, so CREATE only ever adds.
621
+ async #projectLease({ leaseId, messageId }) {
622
+ const scratch = await createLease({ projectRoot: this.#projectRoot, leaseId });
623
+ return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
624
+ }
625
+
626
+ // Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
627
+ async #artifactsFrom(files) {
628
+ const out = [];
629
+ for (const file of files) {
630
+ if (file.status !== 'A') continue;
631
+ const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
632
+ out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: contentTypeFor(file.path), status: 'created' });
633
+ }
634
+ return out.sort((a, b) => a.path.localeCompare(b.path));
610
635
  }
611
636
 
612
637
  // Agents that can receive a delegated step from `self`.
@@ -714,7 +739,7 @@ export class Room {
714
739
  // The orchestrator's turn is over before its plan starts, so it is never
715
740
  // counted as in flight while the others work.
716
741
  if (outcome?.directives?.steps.length) {
717
- await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode });
742
+ await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode, mode });
718
743
  }
719
744
  return outcome?.responseMessageId ?? null;
720
745
  }
@@ -745,13 +770,19 @@ export class Room {
745
770
  const turnScopes = { web: enabled.web, imageGen: enabled.imageGen };
746
771
  let lease = null;
747
772
  let controlRun = null;
748
- if (mode === 3 && requester === 'you' && depth === 0) {
773
+ let createRun = null;
774
+ if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
749
775
  // CONTROL: the project itself is the writable root, and a checkpoint
750
- // taken now makes every change of this turn reversible.
751
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
752
- controlRun = seat.run;
753
- lease = seat.lease;
754
- await this.#emit('control.started', seat.announcement);
776
+ // taken now makes every change of this turn reversible. A plan step gets
777
+ // it when the orchestrator, itself in #3, named #3 for that step.
778
+ if (this.#controlDesk.holder) {
779
+ await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
780
+ } else {
781
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode });
782
+ controlRun = seat.run;
783
+ lease = seat.lease;
784
+ await this.#emit('control.started', seat.announcement);
785
+ }
755
786
  } else if (sharedLease) {
756
787
  lease = enabled.write ? {
757
788
  ...sharedLease,
@@ -760,8 +791,7 @@ export class Room {
760
791
  } else if (agentScopes.write.always && enabled.write && requester !== 'you' && requester !== 'mother') {
761
792
  // Standing lease for a delegate: the human opted this agent into
762
793
  // creating files on every turn, so a plan step gets its own directory.
763
- lease = await createLease({ projectRoot: this.#projectRoot, leaseId: randomUUID() });
764
- lease.messageId = messageId;
794
+ lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
765
795
  lease.scopeCeiling = { write: true, imageGen: false, web: true };
766
796
  lease.scopes = { ...enabled, imageGen: false };
767
797
  await this.#emit('lease.granted', {
@@ -770,11 +800,18 @@ export class Room {
770
800
  messageId,
771
801
  agent: agent.id,
772
802
  outDir: lease.relativeDir,
803
+ scratchDir: lease.scratchDir,
773
804
  scopes: ['write'],
774
805
  unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
775
806
  planId,
776
807
  });
777
808
  }
809
+ if (lease?.create && !controlRun) {
810
+ // CREATE: photograph the project now; after the turn only what appeared stays.
811
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
812
+ createRun = seat.run;
813
+ lease = { ...lease, checkpoint: seat.run.checkpoint };
814
+ }
778
815
  turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
779
816
  // The step asks for files but nobody granted a lease: say so now, with a
780
817
  // way out, instead of letting the agent's refusal be the only signal.
@@ -793,23 +830,27 @@ export class Room {
793
830
  const native = Boolean(CAPABILITIES[agent.id]?.imageGen);
794
831
  const studio = imageModuleState();
795
832
  const imageStudio = lease?.scopes?.imageGen && enabled.imageGen && !native && studio.enabled
796
- ? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.outDir })
833
+ ? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
797
834
  : null;
798
835
  // The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
799
- const turnMode = mode === 0 ? 0 : controlRun ? 3 : lease ? 2 : 1;
836
+ const turnMode = mode === 0 ? 0 : controlRun ? controlRun.mode : lease ? 2 : 1;
800
837
  try {
801
838
  const invoke = this.#invokers[agent.adapter];
802
839
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
803
- const before = lease && !lease.control ? await snapshot(lease.outDir) : null;
840
+ const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
804
841
  const responseMessageId = randomUUID();
805
842
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
806
843
  const others = this.delegatesFor(agent.id);
807
844
  const mayDelegate = allowDelegation && this.#delegation && depth === 0;
845
+ // Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
846
+ const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
847
+ ? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
848
+ : [];
808
849
  const result = await invoke({
809
850
  executable: agent.path,
810
851
  projectRoot: this.#projectRoot,
811
852
  text,
812
- prompt: this.#prompt({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ashCode, mode: turnMode, escalation, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null }),
853
+ prompt: this.#prompt({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ashCode, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null }),
813
854
  timeoutMs: this.timeoutFor(agent.id),
814
855
  signal,
815
856
  model,
@@ -818,6 +859,7 @@ export class Room {
818
859
  scopes: turnScopes,
819
860
  imageStudio,
820
861
  memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
862
+ mcpServers,
821
863
  // For @madre: who asks, who it may convene, and whether a plan would run at all.
822
864
  requester,
823
865
  crew: others,
@@ -828,7 +870,9 @@ export class Room {
828
870
  // the archivist or any other agent can see it. The reply says how many, not which.
829
871
  const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
830
872
  if (guarded.hits) result.text = guarded.text;
831
- const artifacts = lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
873
+ // CREATE: what appeared stays and is shown; what existed before is put back and said.
874
+ const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
875
+ const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
832
876
  // CONTROL: what really changed in the project, forbidden zones reverted on the spot.
833
877
  const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
834
878
  const directives = mayDelegate
@@ -866,6 +910,9 @@ export class Room {
866
910
  if (artifacts.length) {
867
911
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
868
912
  }
913
+ if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
914
+ await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
915
+ }
869
916
  // What the agent saved through memory_note during this turn, for the bubble's hint.
870
917
  if (this.#memory && turnMode !== 0) {
871
918
  const noted = this.#memory.notesSince(notesBefore, { agent: agent.id });
@@ -884,24 +931,32 @@ export class Room {
884
931
  } finally {
885
932
  // Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
886
933
  await this.#controlDesk.release(controlRun);
934
+ await this.#controlDesk.release(createRun);
887
935
  }
888
936
  }
889
937
 
890
- async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false }) {
938
+ async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false, mode = 1 }) {
891
939
  const planId = randomUUID();
892
- // The plan runs at the human's mode: #2 only while a lease exists, and a
893
- // step's agent gets #2 only if its own scopes allow writing.
894
- const stepMode = (agentId) => (lease && this.scopesFor(agentId).write.enabled ? 2 : 1);
940
+ // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
941
+ // lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
942
+ // of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
943
+ const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
944
+ const stepMode = (step) => {
945
+ const scopes = this.scopesFor(step.agent);
946
+ const asked = normalizeMode(step.mode, lease ? 2 : 1);
947
+ const granted = Math.min(asked, ceiling, scopes.maxMode);
948
+ return granted >= 2 && !scopes.write.enabled ? 1 : granted;
949
+ };
895
950
  const plan = { planId, orchestrator, steps: directives.steps, closing: directives.closing, step: 0, stopped: null, startedAt: Date.now() };
896
951
  this.#plans.set(planId, plan);
897
952
  await this.#emit('plan.created', {
898
953
  planId,
899
954
  orchestrator,
900
955
  parentMessageId,
901
- steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step.agent) })),
956
+ steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
902
957
  closing: directives.closing,
903
958
  ignored: directives.ignored,
904
- mode: lease ? 2 : 1,
959
+ mode: ceiling,
905
960
  leaseId: lease?.leaseId ?? null,
906
961
  });
907
962
  try {
@@ -917,7 +972,16 @@ export class Room {
917
972
  let stepLease = lease;
918
973
  let escalation = null;
919
974
  const stepScopes = this.scopesFor(step.agent);
920
- if (!lease && !stepScopes.write.always && looksLikeCreation(step.text) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
975
+ const wanted = stepMode(step);
976
+ // Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
977
+ // a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
978
+ if (ceiling >= 3 && !stepLease && wanted === 2) {
979
+ stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
980
+ stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
981
+ stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
982
+ await this.#emit('lease.granted', { delegated: true, leaseId: stepLease.leaseId, messageId, agent: step.agent, outDir: '.', scratchDir: stepLease.scratchDir, grantedBy: orchestrator, scopes: SCOPES.filter((scope) => stepLease.scopes[scope]), unavailable: [], planId });
983
+ }
984
+ if (ceiling < 3 && !lease && !stepScopes.write.always && (wanted >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
921
985
  const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + (directives.closing ? 1 : 0), orchestrator, parentMessageId, mode: 2 });
922
986
  if (outcome.scope === 'plan') lease = outcome.lease;
923
987
  if (outcome.scope) stepLease = outcome.lease; else escalation = outcome.reason;
@@ -933,12 +997,12 @@ export class Room {
933
997
  ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
934
998
  status: 'delegated',
935
999
  planId,
936
- mode: stepLease && stepScopes.write.enabled ? 2 : 1,
1000
+ mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
937
1001
  escalation: escalation ?? undefined,
938
1002
  step: index + 1,
939
1003
  totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
940
1004
  });
941
- await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: stepLease, ashCode, mode: stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
1005
+ await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ashCode, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
942
1006
  }
943
1007
  if (!plan.stopped && directives.closing) {
944
1008
  plan.step = directives.steps.length + 1;
package/src/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { memoryServerFor } from './memory-tools.mjs';
13
13
  import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
14
14
  import { ErrorSentinel } from './sentinel-errors.mjs';
15
15
  import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
16
- import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
16
+ import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn } from './modules/index.mjs';
17
17
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
18
18
  import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
19
19
 
@@ -43,7 +43,8 @@ import { extensionById, runInstaller } from './extensions.mjs';
43
43
  import { loginPlanFor, probeAll } from './auth-probe.mjs';
44
44
  import { loadConfig as readConfig, updateConfig } from './config.mjs';
45
45
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
46
- import { checkForUpdate, detectInstall, updateCommand, releaseUrl } from './updates.mjs';
46
+ import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
47
+ import { spawn } from 'node:child_process';
47
48
  import { totalmem } from 'node:os';
48
49
  import { discoverModels } from './models.mjs';
49
50
  import { setImageModule } from './capabilities.mjs';
@@ -207,11 +208,15 @@ export async function createPulseServer({
207
208
  const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
208
209
  const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
209
210
  const startupMemory = await memoryConfig();
211
+ let listening = null;
212
+ const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
210
213
  room = new Room({
211
214
  store,
212
215
  agents,
213
216
  projectRoot,
214
217
  privacy,
218
+ // Modules may hand tools to a turn; the room asks once per turn, the server knows the port.
219
+ toolsForTurn: async (turn) => modulesToolsForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir }),
215
220
  softTokenBudget,
216
221
  contextMaxChars,
217
222
  recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
@@ -323,7 +328,9 @@ export async function createPulseServer({
323
328
  if (!agents.some((agent) => agent.id === agentId) || !scopes || typeof scopes !== 'object') continue;
324
329
  config.scopes[agentId] = { ...(current[agentId] ?? {}) };
325
330
  for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
326
- if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) config.scopes[agentId].maxMode = Number(scopes.maxMode);
331
+ // MAX MODE and DEFAULT MODE are the two controls; setting them retires the older flags they replace.
332
+ if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 4) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
333
+ if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
327
334
  }
328
335
  room.setScopes(config.scopes);
329
336
  }
@@ -784,6 +791,26 @@ export async function createPulseServer({
784
791
  if (request.method === 'GET' && url.pathname === '/api/version') {
785
792
  return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
786
793
  }
794
+ // One click: install the newer version and come back on the same port. The room records it,
795
+ // closes its listener so the port is free, hands the command to a detached shell and exits.
796
+ if (request.method === 'POST' && url.pathname === '/api/updates/apply') {
797
+ const info = await versionView();
798
+ if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
799
+ const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
800
+ if (!command) return sendJson(response, 412, { error: 'This copy runs from source: pull the repository and start it again.' });
801
+ if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: 'Agents are still working. STOPALL or wait, then update.' });
802
+ await room.record('room.updating', { from: info.current, to: info.latest, install, command });
803
+ sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
804
+ setTimeout(() => {
805
+ server.close(() => {
806
+ const child = spawn('/bin/sh', ['-c', command], { cwd: canonicalProjectRoot, detached: true, stdio: 'ignore', env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
807
+ child.unref();
808
+ setTimeout(() => process.exit(0), 200);
809
+ });
810
+ for (const client of clients.keys()) { try { client.end(); } catch { /* gone */ } }
811
+ }, 300);
812
+ return undefined;
813
+ }
787
814
  if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
788
815
  const patch = await body(request).catch(() => ({}));
789
816
  if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
@@ -903,6 +930,7 @@ export async function createPulseServer({
903
930
  sendJson(response, 400, { error: error.message });
904
931
  }
905
932
  });
933
+ listening = server;
906
934
  // `server.close()` only resolves once every connection has ended, so the
907
935
  // long-lived SSE responses must be ended before the native close runs.
908
936
  const nativeClose = server.close.bind(server);
package/src/updates.mjs CHANGED
@@ -38,6 +38,19 @@ export function updateCommand(install, name, version = 'latest') {
38
38
  }
39
39
  }
40
40
 
41
+ // One shell line that installs the version and starts the same room on the same port.
42
+ // The room's own process runs it detached after closing its listener, then exits.
43
+ const shq = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
44
+ export function applyCommand({ install, name, version, port, projectRoot }) {
45
+ const start = `start --no-open --port ${Number(port) || 4317} --project ${shq(projectRoot)}`;
46
+ switch (install) {
47
+ case 'npx': return `exec npx -y ${name}@${version} ${start}`;
48
+ case 'project': return `npm install ${name}@${version} --no-fund --no-audit && exec npx --no ${name.split('/').pop()} ${start}`;
49
+ case 'global': return `npm install -g ${name}@${version} --no-fund --no-audit && exec ${name.split('/').pop()} ${start}`;
50
+ default: return null; // source: the human pulls
51
+ }
52
+ }
53
+
41
54
  export function releaseUrl(repository, version) {
42
55
  const url = String(repository?.url ?? repository ?? '').replace(/^git\+/, '').replace(/\.git$/, '');
43
56
  return url ? `${url}/releases/tag/v${version}` : null;