@stage5/lumine 0.2.66 → 0.2.68

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.
@@ -1,5 +1,6 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import fs from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -10,6 +11,10 @@ import { requestJson } from "./http.js";
10
11
  import { sleep } from "./util.js";
11
12
  import { collectProjectFiles } from "./workspace.js";
12
13
 
14
+ const ACTIVE_JOB_STATUSES = new Set(["leased", "working", "waiting_user"]);
15
+ // Colliding duty commands wait this long for the state lock before failing.
16
+ const SPONSOR_LOCK_WAIT_MS = 15_000;
17
+
13
18
  const SPONSOR_PATH = "/cli/sponsor";
14
19
  const EXECUTION_MODE = "agent_session_v2";
15
20
  const STATE_VERSION = 2;
@@ -61,7 +66,8 @@ export async function sponsorDutyCommand(options, args, commandServices) {
61
66
  return;
62
67
  }
63
68
  if (action !== "start") throw new Error(sponsorDutyUsage());
64
- await withSponsorStateLock(options, () => startDuty(options));
69
+ const startOptions = { ...options, sponsorStateForStart: true };
70
+ await withSponsorStateLock(startOptions, () => startDuty(startOptions));
65
71
  }
66
72
 
67
73
  export async function sponsorJobCommand(options, args, commandServices) {
@@ -112,6 +118,43 @@ export async function sponsorJobCommand(options, args, commandServices) {
112
118
  );
113
119
  return;
114
120
  }
121
+ if (action === "sync-main") {
122
+ assertNoExtraArgs(args, 2);
123
+ await withSponsorStateLock(options, () =>
124
+ syncJobFromMain(options, jobId, commandServices),
125
+ );
126
+ return;
127
+ }
128
+ if (action === "assets") {
129
+ await withSponsorStateLock(options, () =>
130
+ jobAssets(options, jobId, args.slice(2), commandServices),
131
+ );
132
+ return;
133
+ }
134
+ if (action === "hold") {
135
+ assertNoExtraArgs(args, 2);
136
+ await holdJobLease(options, jobId);
137
+ return;
138
+ }
139
+ if (action === "rename" || action === "describe") {
140
+ await withSponsorStateLock(options, () =>
141
+ jobDetails(options, jobId, action, args.slice(2).join(" "), commandServices),
142
+ );
143
+ return;
144
+ }
145
+ if (action === "suggest") {
146
+ assertNoExtraArgs(args, 3);
147
+ await withSponsorStateLock(options, () =>
148
+ jobSuggest(options, jobId, String(args[2] || ""), commandServices),
149
+ );
150
+ return;
151
+ }
152
+ if (action === "thumbnail") {
153
+ await withSponsorStateLock(options, () =>
154
+ jobThumbnail(options, jobId, args.slice(2), commandServices),
155
+ );
156
+ return;
157
+ }
115
158
  if (action === "complete") {
116
159
  assertNoExtraArgs(args, 2);
117
160
  await withSponsorStateLock(options, () =>
@@ -215,7 +258,7 @@ async function startDuty(options) {
215
258
  `Shared Zero/Ciel sponsor duty #${dutyId} is open under this ${displayProvider(provider)} session.`,
216
259
  `Declared runtime: model=${requestedModel}, effort=${requestedEffort}, service-tier=${options.sponsorServiceTier || "provider default"}.`,
217
260
  "This same live agent session must keep checking in, receive approved plans, and perform the work itself.",
218
- "Do not leave an unattended heartbeat running and do not hand jobs to a newly spawned provider process.",
261
+ "The CLI keeps a lease keeper for each job you begin in this session; never hand jobs to a newly spawned provider process.",
219
262
  "Run `lumine sponsor duty watch` repeatedly to stay present and receive an assignment.",
220
263
  ],
221
264
  );
@@ -543,12 +586,6 @@ async function changeDutyState(options, action) {
543
586
  }
544
587
  throw new Error("No active sponsor duty session was found.");
545
588
  }
546
- if (duties.length > 1) {
547
- throw new Error(
548
- `Multiple legacy duty sessions remain open (${duties.map((duty) => `#${duty.id}`).join(", ")}). Stop them before using shared duty.`,
549
- );
550
- }
551
- const dutyId = Number(duties[0].id || 0);
552
589
  const localRecord =
553
590
  action === "stop"
554
591
  ? await readSponsorStateForStop(options)
@@ -558,6 +595,20 @@ async function changeDutyState(options, action) {
558
595
  invalidState: null,
559
596
  };
560
597
  let localState = localRecord.state;
598
+ // A sponsor may keep several live sessions on duty (a pool). This session
599
+ // acts on the duty its own local record names; only without a local
600
+ // record is more than one open duty ambiguous.
601
+ const localDutyId = Number(localState?.duty?.id || 0);
602
+ const ownDuty =
603
+ localDutyId > 0
604
+ ? duties.find((duty) => Number(duty.id) === localDutyId)
605
+ : null;
606
+ if (!ownDuty && duties.length > 1) {
607
+ throw new Error(
608
+ `Multiple duty sessions remain open (${duties.map((duty) => `#${duty.id}`).join(", ")}) and this session owns none of them. Run this from the session that started the duty you want to change.`,
609
+ );
610
+ }
611
+ const dutyId = Number((ownDuty || duties[0]).id || 0);
561
612
  if (localState) assertSponsorStateAccount(localState, auth);
562
613
  const preservesAnotherAccountState =
563
614
  sponsorStateBelongsToAnotherAccount(localRecord.invalidState, auth);
@@ -668,17 +719,20 @@ async function beginJob(options, jobId) {
668
719
  }
669
720
  const agent = await ensureCoordinator({ options, auth, state, jobState });
670
721
  jobState.coordinator = agent;
722
+ if (!options.sponsorNoLeaseKeeper) startJobLeaseKeeper({ options, state, jobState });
671
723
  await writeSponsorState(options, state);
672
724
  ({ state } = await heartbeatJob({ options, auth, state, jobId }));
673
725
  const canonicalJobState = requireJobState(state, jobId);
674
726
  const consultation = isConsultationJob(jobState);
675
- printJsonOrLines(options, { job: canonicalJobState.job, coordinator: agent }, [
727
+ printJsonOrLines(options, { job: canonicalJobState.job, coordinator: agent, leaseKeeperPid: canonicalJobState.keeperPid || null }, [
676
728
  `Workshop job #${jobId} is now being handled by this same on-duty agent session.`,
677
729
  consultation
678
730
  ? `Inspect the approved project in ${jobState.workspaceDir} without editing it, then answer the approved question.`
679
731
  : `Apply the approved plan in ${jobState.workspaceDir}.`,
680
732
  `Introduce yourself to ${displayPersona(jobState.job.persona)} as Lumine with: lumine sponsor job update ${jobId} --file <message-file> --phase starting`,
681
- `Check in with: lumine sponsor job pulse ${jobId}`,
733
+ canonicalJobState.keeperPid
734
+ ? `A lease keeper (pid ${canonicalJobState.keeperPid}) checks in for this session while you work; still run lumine sponsor job pulse ${jobId} between steps to read approved follow-ups.`
735
+ : `No lease keeper runs for this session; run lumine sponsor job pulse ${jobId} at least every few minutes and between steps.`,
682
736
  ]);
683
737
  }
684
738
 
@@ -693,6 +747,10 @@ async function publishDialogueUpdate(options, jobId) {
693
747
  if (phase.length > 40) {
694
748
  throw new Error("--phase must be at most 40 characters.");
695
749
  }
750
+ const kind = String(options.sponsorUpdateKind || "progress").trim().toLowerCase();
751
+ if (kind !== "progress" && kind !== "question") {
752
+ throw new Error("--kind must be progress (default) or question.");
753
+ }
696
754
  const messagePath = path.resolve(messageFile);
697
755
  const messageStat = await fs.stat(messagePath);
698
756
  if (!messageStat.isFile() || messageStat.size > 8_000) {
@@ -721,6 +779,7 @@ async function publishDialogueUpdate(options, jobId) {
721
779
  clientUpdateKey,
722
780
  phase: phase || null,
723
781
  message,
782
+ kind,
724
783
  },
725
784
  }),
726
785
  options.signal,
@@ -729,11 +788,44 @@ async function publishDialogueUpdate(options, jobId) {
729
788
  throw new Error("Twinkle did not confirm the canonical Lumine update.");
730
789
  }
731
790
  printJsonOrLines(options, result, [
732
- `Lumine → ${displayPersona(jobState.job.persona)}:`,
791
+ `Lumine → ${displayPersona(jobState.job.persona)}${kind === "question" ? " (question for the user)" : ""}:`,
733
792
  result.update.message,
793
+ ...(kind === "question"
794
+ ? [`The answer arrives as an approved follow-up relay: keep running lumine sponsor job pulse ${jobId} and read the refreshed assignment.`]
795
+ : []),
796
+ ]);
797
+ }
798
+
799
+ // Title and description of the approved workspace, under the job identity.
800
+ async function jobDetails(options, jobId, field, text, commandServices) {
801
+ const value = String(text || "").trim();
802
+ if (!value) throw new Error(`Usage: lumine sponsor job ${field} <job-id> <text>`);
803
+ const auth = await ensureSponsorAuth(options);
804
+ let state = await loadOwnedState({ options, auth });
805
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
806
+ const jobState = requireJobState(state, jobId);
807
+ if (isConsultationJob(jobState)) {
808
+ throw new Error("A read-only consultation cannot change project details.");
809
+ }
810
+ if (jobState.job?.targetBuild?.kind !== "main") {
811
+ throw new Error(
812
+ "Title and description belong to the project's Main; a branch keeps the original details.",
813
+ );
814
+ }
815
+ const fn = field === "rename" ? commandServices?.renameBuild : commandServices?.describeBuild;
816
+ if (typeof fn !== "function") throw new Error("This Lumine CLI build cannot update job project details.");
817
+ const jobOptions = jobWorkspaceOptions(options, state, jobState, {
818
+ json: false,
819
+ positional: [],
820
+ ...(field === "rename" ? { title: value } : { description: value, descriptionProvided: true, noDescription: false }),
821
+ });
822
+ const output = await runJobScopedCommand(options, jobOptions, (o) => fn(o));
823
+ printJsonOrLines(options, { job: jobState.job, [field]: value, output }, [
824
+ field === "rename" ? `Workshop job #${jobId}: project renamed to "${value}".` : `Workshop job #${jobId}: project description updated.`,
734
825
  ]);
735
826
  }
736
827
 
828
+
737
829
  async function startHelper(options, jobId) {
738
830
  const auth = await ensureSponsorAuth(options);
739
831
  let state = await loadOwnedState({ options, auth });
@@ -840,6 +932,269 @@ async function markRelaysApplied(options, jobId, relayIds) {
840
932
  ]);
841
933
  }
842
934
 
935
+ // Runs the branch's Main sync under the job identity so the server can
936
+ // advance the job's restore point to the merged snapshot. Only editing jobs
937
+ // on a requester-owned branch can sync.
938
+ async function syncJobFromMain(options, jobId, commandServices) {
939
+ const auth = await ensureSponsorAuth(options);
940
+ let state = await loadOwnedState({ options, auth });
941
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
942
+ const jobState = requireJobState(state, jobId);
943
+ if (isConsultationJob(jobState)) {
944
+ throw new Error("A read-only consultation never changes the branch.");
945
+ }
946
+ if (jobState.job?.targetBuild?.kind !== "branch") {
947
+ throw new Error(
948
+ "Only a requester-owned branch can be updated from Main; this job targets Main itself.",
949
+ );
950
+ }
951
+ if (typeof commandServices?.updateFromMain !== "function") {
952
+ throw new Error("This Lumine CLI build cannot sync a job branch from Main.");
953
+ }
954
+ const jobOptions = jobWorkspaceOptions(options, state, jobState, {
955
+ json: false,
956
+ quiet: Boolean(options.quiet),
957
+ });
958
+ // The shared update-from-main printer writes plain lines; keep --json
959
+ // output pure by collecting them into the result instead.
960
+ const syncLines = [];
961
+ const originalLog = console.log;
962
+ if (options.json) console.log = (...parts) => syncLines.push(parts.join(" "));
963
+ try {
964
+ await commandServices.updateFromMain(jobOptions);
965
+ } finally {
966
+ console.log = originalLog;
967
+ }
968
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
969
+ const refreshed = requireJobState(state, jobId);
970
+ refreshed.initialFileHashes = hashProjectFiles(
971
+ await collectProjectFiles(refreshed.workspaceDir),
972
+ );
973
+ await writeAssignment(refreshed, state);
974
+ await writeSponsorState(options, state);
975
+ printJsonOrLines(options, { job: refreshed.job, sync: syncLines }, [
976
+ `Workshop job #${jobId}: branch synced from Main under the job identity.`,
977
+ `Restore point is now ${refreshed.job?.restorePoint ? `artifact version #${refreshed.job.restorePoint.versionNumber || refreshed.job.restorePoint.artifactVersionId}` : "unchanged"}.`,
978
+ ]);
979
+ }
980
+
981
+ // Uploads generated media into the approved workspace's asset space under
982
+ // the job identity (the requester's own uploads for that build).
983
+ async function jobAssets(options, jobId, args, commandServices) {
984
+ const sub = String(args[0] || "").trim().toLowerCase();
985
+ const files = args.slice(1);
986
+ if (sub !== "upload" || files.length === 0) {
987
+ throw new Error(
988
+ `Usage: lumine sponsor job assets ${jobId} upload <file...>`,
989
+ );
990
+ }
991
+ const auth = await ensureSponsorAuth(options);
992
+ let state = await loadOwnedState({ options, auth });
993
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
994
+ const jobState = requireJobState(state, jobId);
995
+ if (isConsultationJob(jobState)) {
996
+ throw new Error("A read-only consultation cannot add assets.");
997
+ }
998
+ if (typeof commandServices?.uploadAssets !== "function") {
999
+ throw new Error("This Lumine CLI build cannot upload job assets.");
1000
+ }
1001
+ const jobOptions = jobWorkspaceOptions(options, state, jobState, {
1002
+ json: Boolean(options.json),
1003
+ quiet: false,
1004
+ positional: ["upload", ...files],
1005
+ skipAssetManifest: false,
1006
+ });
1007
+ await commandServices.uploadAssets(jobOptions);
1008
+ }
1009
+
1010
+ // Runs a shared CLI command under the job identity with its plain output
1011
+ // collected, so a --json caller still gets pure JSON.
1012
+ async function runJobScopedCommand(options, jobOptions, fn) {
1013
+ const lines = [];
1014
+ const originalLog = console.log;
1015
+ if (options.json) console.log = (...parts) => lines.push(parts.join(" "));
1016
+ try {
1017
+ await fn(jobOptions);
1018
+ } finally {
1019
+ console.log = originalLog;
1020
+ }
1021
+ return lines;
1022
+ }
1023
+
1024
+ // Hands the approved branch to the project owner (branch suggestion with a
1025
+ // note Lumine wrote) or suggests the branch's thumbnail, under the job
1026
+ // identity. Only editing jobs on a requester-owned branch can suggest.
1027
+ async function jobSuggest(options, jobId, kind, commandServices) {
1028
+ const suggestionAction = String(kind || "").trim().toLowerCase();
1029
+ if (suggestionAction !== "branch" && suggestionAction !== "thumbnail") {
1030
+ throw new Error(
1031
+ `Usage: lumine sponsor job suggest ${jobId} branch --note <message> | lumine sponsor job suggest ${jobId} thumbnail`,
1032
+ );
1033
+ }
1034
+ const note = String(options.note || "").trim();
1035
+ if (suggestionAction === "branch" && !note) {
1036
+ throw new Error(
1037
+ "Write the note for the project owner with --note <message>; Lumine composes it from the actual work, never from dictation.",
1038
+ );
1039
+ }
1040
+ const auth = await ensureSponsorAuth(options);
1041
+ let state = await loadOwnedState({ options, auth });
1042
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
1043
+ const jobState = requireJobState(state, jobId);
1044
+ if (isConsultationJob(jobState)) {
1045
+ throw new Error("A read-only consultation cannot suggest anything.");
1046
+ }
1047
+ if (jobState.job?.targetBuild?.kind !== "branch") {
1048
+ throw new Error(
1049
+ "Suggestions go from a requester-owned branch to the project owner; this job targets Main itself.",
1050
+ );
1051
+ }
1052
+ const send =
1053
+ suggestionAction === "branch"
1054
+ ? commandServices?.notifyOwner
1055
+ : commandServices?.suggestThumbnail;
1056
+ if (typeof send !== "function") {
1057
+ throw new Error("This Lumine CLI build cannot send job suggestions.");
1058
+ }
1059
+ const jobOptions = jobWorkspaceOptions(options, state, jobState, { json: false });
1060
+ const jobAuth = await resolveAuth(jobOptions);
1061
+ const result = await send({
1062
+ options: jobOptions,
1063
+ auth: jobAuth,
1064
+ rootBuildId: Number(jobState.job.rootBuild.id),
1065
+ contributionBuildId: Number(jobState.job.targetBuild.id),
1066
+ ...(suggestionAction === "branch" ? { note } : {}),
1067
+ });
1068
+ const messageId = Number(result?.message?.id || 0);
1069
+ // The hand-off message is this job's proof of work when no file changed.
1070
+ if (messageId > 0) {
1071
+ jobState.handoffMessageIds = Array.from(
1072
+ new Set([...(jobState.handoffMessageIds || []), messageId]),
1073
+ );
1074
+ await writeSponsorState(options, state);
1075
+ }
1076
+ printJsonOrLines(
1077
+ options,
1078
+ { job: jobState.job, suggestion: suggestionAction, messageId: messageId || null },
1079
+ [
1080
+ suggestionAction === "branch"
1081
+ ? `Workshop job #${jobId}: branch suggested to the project owner with Lumine's note.`
1082
+ : `Workshop job #${jobId}: thumbnail suggested to the project owner.`,
1083
+ ],
1084
+ );
1085
+ }
1086
+
1087
+ // Sets the approved branch's thumbnail from a local image under the job
1088
+ // identity (upload + metadata commit). Pair with `suggest <id> thumbnail`.
1089
+ async function jobThumbnail(options, jobId, args, commandServices) {
1090
+ const sub = String(args[0] || "").trim().toLowerCase();
1091
+ const file = String(args[1] || "").trim();
1092
+ if (sub !== "set" || !file || args.length > 2) {
1093
+ throw new Error(`Usage: lumine sponsor job thumbnail ${jobId} set <file>`);
1094
+ }
1095
+ const auth = await ensureSponsorAuth(options);
1096
+ let state = await loadOwnedState({ options, auth });
1097
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
1098
+ const jobState = requireJobState(state, jobId);
1099
+ if (isConsultationJob(jobState)) {
1100
+ throw new Error("A read-only consultation cannot change the thumbnail.");
1101
+ }
1102
+ if (typeof commandServices?.thumbnailCommand !== "function") {
1103
+ throw new Error("This Lumine CLI build cannot set job thumbnails.");
1104
+ }
1105
+ const jobOptions = jobWorkspaceOptions(options, state, jobState, {
1106
+ json: false,
1107
+ yes: true,
1108
+ positional: ["set", file],
1109
+ });
1110
+ const output = await runJobScopedCommand(options, jobOptions, (o) =>
1111
+ commandServices.thumbnailCommand(o),
1112
+ );
1113
+ printJsonOrLines(options, { job: jobState.job, output }, [
1114
+ `Workshop job #${jobId}: thumbnail set on the approved branch.`,
1115
+ ]);
1116
+ }
1117
+
1118
+ // Foreground lease keeper: pulses the job while the owning session works.
1119
+ // `job begin` starts one detached child per job; complete/fail stop it.
1120
+ async function holdJobLease(options, jobId) {
1121
+ const every = Math.max(15, Number(options.sponsorHoldEverySeconds || 40));
1122
+ let consecutiveFailures = 0;
1123
+ for (;;) {
1124
+ let keepGoing = true;
1125
+ try {
1126
+ keepGoing = await withSponsorStateLock(options, async () => {
1127
+ const auth = await ensureSponsorAuth(options);
1128
+ const state = await loadOwnedState({ options, auth });
1129
+ if (!state.jobs?.[String(jobId)]) return false;
1130
+ const { state: next } = await heartbeatJob({
1131
+ options,
1132
+ auth,
1133
+ state,
1134
+ jobId,
1135
+ });
1136
+ const jobState = next.jobs?.[String(jobId)];
1137
+ return Boolean(
1138
+ jobState && ACTIVE_JOB_STATUSES.has(String(jobState.job?.status)),
1139
+ );
1140
+ });
1141
+ } catch (error) {
1142
+ const message = String(error?.message || error);
1143
+ // Lease or duty gone: nothing left to hold.
1144
+ if (/no longer active|lease|No local agent-owned duty|does not exist/i.test(message)) {
1145
+ return;
1146
+ }
1147
+ consecutiveFailures += 1;
1148
+ console.error(`lumine: lease keeper check-in failed: ${message}`);
1149
+ // The owning session's own commands re-establish the lease; a keeper
1150
+ // that cannot reach Twinkle three times in a row must not linger.
1151
+ if (consecutiveFailures >= 3) return;
1152
+ }
1153
+ if (!keepGoing) return;
1154
+ consecutiveFailures = 0;
1155
+ await new Promise((resolve) => setTimeout(resolve, every * 1_000));
1156
+ }
1157
+ }
1158
+
1159
+ function startJobLeaseKeeper({ options, state, jobState }) {
1160
+ if (jobState.keeperPid) return;
1161
+ // A detached child is re-parented once this command exits, so a session
1162
+ // identified only by process ancestry could not prove it is the same live
1163
+ // session. Those sessions keep the lease by pulsing between steps instead.
1164
+ if (state?.operatorSession?.bindingEvidence !== "runtime_session_id") {
1165
+ jobState.keeperSkippedReason = "agent_process_ancestry";
1166
+ return;
1167
+ }
1168
+ const args = [
1169
+ process.argv[1],
1170
+ "sponsor",
1171
+ "job",
1172
+ "hold",
1173
+ String(jobState.job.id),
1174
+ "--auth-file",
1175
+ String(options.authFile),
1176
+ "--api-url",
1177
+ String(options.apiUrl),
1178
+ ];
1179
+ const child = spawn(process.execPath, args, {
1180
+ detached: true,
1181
+ stdio: "ignore",
1182
+ env: process.env,
1183
+ });
1184
+ child.unref();
1185
+ jobState.keeperPid = child.pid;
1186
+ jobState.keeperStartedAt = new Date().toISOString();
1187
+ }
1188
+
1189
+ function stopJobLeaseKeeper(jobState) {
1190
+ const pid = Number(jobState?.keeperPid || 0);
1191
+ if (!pid) return;
1192
+ try {
1193
+ process.kill(pid, "SIGTERM");
1194
+ } catch (_) {}
1195
+ jobState.keeperPid = null;
1196
+ }
1197
+
843
1198
  async function completeJob(options, jobId, commandServices) {
844
1199
  const summary = String(options.summary || "").trim();
845
1200
  if (!summary) {
@@ -929,6 +1284,48 @@ async function completeJob(options, jobId, commandServices) {
929
1284
  ]);
930
1285
  return;
931
1286
  }
1287
+ const handoffMessageIds = Array.isArray(jobState.handoffMessageIds)
1288
+ ? jobState.handoffMessageIds.filter((id) => Number(id) > 0).map(Number)
1289
+ : [];
1290
+ if (changedPaths.length === 0 && handoffMessageIds.length > 0) {
1291
+ // Nothing to save: the job's outcome is the hand-off itself (a branch
1292
+ // or thumbnail suggestion sent to the owner under the job identity).
1293
+ const coordinatorResult = await completeAgent({
1294
+ options,
1295
+ auth,
1296
+ state,
1297
+ jobState,
1298
+ agent: jobState.coordinator,
1299
+ outcome: {
1300
+ finalText: summary.slice(0, 2000),
1301
+ changedPathCount: 0,
1302
+ changedPaths: [],
1303
+ agentSessionBound: true,
1304
+ handoffMessageIds,
1305
+ },
1306
+ });
1307
+ jobState.coordinator = { ...jobState.coordinator, ...coordinatorResult };
1308
+ await writeSponsorState(options, state);
1309
+ const result = await retrySponsorTransport(() =>
1310
+ sponsorJobRequest({
1311
+ options,
1312
+ auth,
1313
+ state,
1314
+ jobState,
1315
+ path: "/complete",
1316
+ body: {
1317
+ outcomeSummary: summary,
1318
+ reportedFilesHash: currentDigest,
1319
+ handoffMessageIds,
1320
+ },
1321
+ }),
1322
+ );
1323
+ await removeCompletedJob({ options, state, jobId });
1324
+ printJsonOrLines(options, result, [
1325
+ `Completed Workshop job #${jobId}; the approved branch was handed to the project owner and ${displayPersona(jobState.job.persona)} shared the result.`,
1326
+ ]);
1327
+ return;
1328
+ }
932
1329
  let savedArtifact = jobState.savedArtifact;
933
1330
  const canonicalSavedArtifact = jobState.job?.savedArtifact;
934
1331
  if (
@@ -989,6 +1386,7 @@ async function completeJob(options, jobId, commandServices) {
989
1386
  artifactVersionId: savedArtifact.artifactVersionId,
990
1387
  outcomeSummary: summary,
991
1388
  reportedFilesHash: savedArtifact.filesHash,
1389
+ ...(handoffMessageIds.length > 0 ? { handoffMessageIds } : {}),
992
1390
  },
993
1391
  }),
994
1392
  );
@@ -1463,11 +1861,16 @@ ${jobState.forumContext ? `## Normal-access Build Forum snapshot\n\n${jobState.f
1463
1861
  ## Duty protocol
1464
1862
 
1465
1863
  1. Run \`lumine sponsor job begin ${jobState.job.id}\` before ${consultation ? "inspecting the project" : "editing"}.
1466
- 2. Introduce yourself and publish meaningful milestones with \`lumine sponsor job update ${jobState.job.id} --file <message-file> --phase <name>\`. Speak as Lumine, never as the underlying provider.
1864
+ 2. Introduce yourself and publish meaningful milestones with \`lumine sponsor job update ${jobState.job.id} --file <message-file> --phase <name>\`. Speak as Lumine, never as the underlying provider. To ask the user something you genuinely cannot decide yourself, add \`--kind question\`: ${displayPersona(jobState.job.persona)} puts it to the user and their answer comes back as an approved follow-up relay on your next pulse. Keep working on what does not depend on the answer.
1467
1865
  3. ${consultation ? "Inspect only" : "Work only"} in the workspace above. Use your native same-session subagents only after registering each with \`helper-start\`. The coordinator alone runs sponsor CLI commands; helpers report their results back to it.
1468
- 4. Run \`lumine sponsor job pulse ${jobState.job.id}\` between substantial work steps to keep the lease alive and receive approved follow-ups.
1469
- 5. After applying a relay, record its exact ID with \`lumine sponsor job relay-applied ${jobState.job.id} <relay-id...>\`.
1470
- 6. Finish with \`lumine sponsor job complete ${jobState.job.id} --summary "..."\`. ${consultation ? `For this consultation, the summary is the exact substantive answer ${displayPersona(jobState.job.persona)} will share; no artifact or project change is created.` : "The CLI will save a canonical artifact directly to the approved workspace. Publishing remains a separate owner action."}
1866
+ 4. Run \`lumine sponsor job pulse ${jobState.job.id}\` between substantial work steps to receive approved follow-ups. A CLI lease keeper for this same session checks in while you work, so multi-minute steps are fine; it stops when the job completes or fails.
1867
+ 5. After applying a relay, record its exact ID with \`lumine sponsor job relay-applied ${jobState.job.id} <relay-id...>\`.${
1868
+ consultation
1869
+ ? ""
1870
+ : `
1871
+ 6. ${consultation ? "" : `For a new or renamed project, set its title and description with \`lumine sponsor job rename ${jobState.job.id} <title>\` and \`lumine sponsor job describe ${jobState.job.id} <text>\`, and its thumbnail with \`lumine sponsor job thumbnail ${jobState.job.id} set <file>\`. `}If the plan asks for the branch to be updated from Main, run \`lumine sponsor job sync-main ${jobState.job.id}\` FIRST, before editing anything (never your own login); it merges under the job identity and moves the restore point to the merged snapshot, so work done before the sync would be folded into the restore point and no longer count as this job's change. Generated images or audio go in with \`lumine sponsor job assets ${jobState.job.id} upload <file...>\` and are referenced by the printed URLs. When the plan asks you to hand the branch to the project owner, finish with \`lumine sponsor job suggest ${jobState.job.id} branch --note <message>\` using a note you wrote from the actual work (optionally \`lumine sponsor job thumbnail ${jobState.job.id} set <file>\` then \`lumine sponsor job suggest ${jobState.job.id} thumbnail\`); never ask the user for wording.`
1872
+ }
1873
+ ${consultation ? "6" : "7"}. Finish with \`lumine sponsor job complete ${jobState.job.id} --summary "..."\`. ${consultation ? `For this consultation, the summary is the exact substantive answer ${displayPersona(jobState.job.persona)} will share; no artifact or project change is created.` : "The CLI will save a canonical artifact directly to the approved workspace. Publishing remains a separate owner action."}
1471
1874
  `;
1472
1875
  await fs.writeFile(jobState.assignmentPath, content, {
1473
1876
  encoding: "utf8",
@@ -1537,7 +1940,7 @@ async function loadOwnedState({ options, auth, state = null }) {
1537
1940
  return loaded;
1538
1941
  }
1539
1942
 
1540
- export function sponsorDutyStatePath(options) {
1943
+ export function baseSponsorDutyStatePath(options) {
1541
1944
  const authFile = path.resolve(options.authFile);
1542
1945
  const contextHash = createHash("sha256")
1543
1946
  .update(String(options.apiUrl || ""))
@@ -1551,6 +1954,36 @@ export function sponsorDutyStatePath(options) {
1551
1954
  );
1552
1955
  }
1553
1956
 
1957
+ // One sponsor login can keep several live agent sessions on duty at once.
1958
+ // The first session owns the base state file. When a further session on the
1959
+ // same login STARTS duty while the base belongs to another live session, it
1960
+ // gets its own file keyed by its session fingerprint; from then on that
1961
+ // session always finds its own file. Every other command still sees the base
1962
+ // record, so the account and same-session guards keep working.
1963
+ export function sponsorDutyStatePath(options) {
1964
+ const base = baseSponsorDutyStatePath(options);
1965
+ let session = null;
1966
+ try {
1967
+ session = detectSponsorAgentSession();
1968
+ } catch {
1969
+ return base;
1970
+ }
1971
+ const scoped = base.replace(
1972
+ /\.json$/,
1973
+ `-${session.fingerprintHash.slice(0, 12)}.json`,
1974
+ );
1975
+ if (existsSync(scoped)) return scoped;
1976
+ if (!options.sponsorStateForStart) return base;
1977
+ try {
1978
+ const state = JSON.parse(readFileSync(base, "utf8"));
1979
+ const owner = String(state?.operatorSession?.fingerprintHash || "");
1980
+ if (owner && owner !== session.fingerprintHash) return scoped;
1981
+ } catch {
1982
+ // No base file (or unreadable): this session takes the base path.
1983
+ }
1984
+ return base;
1985
+ }
1986
+
1554
1987
  function assertSponsorStateAccount(state, auth) {
1555
1988
  if (
1556
1989
  auth.userId &&
@@ -1686,18 +2119,27 @@ async function withSponsorStateLock(options, operation) {
1686
2119
  const lockDirectory = path.dirname(lockPath);
1687
2120
  await fs.mkdir(lockDirectory, { recursive: true, mode: 0o700 });
1688
2121
  let handle;
1689
- try {
1690
- handle = await fs.open(lockPath, "wx", 0o600);
1691
- } catch (error) {
1692
- if (error.code !== "EEXIST") throw error;
1693
- const stale = await sponsorLockIsStale(lockPath);
1694
- if (!stale) {
1695
- throw new Error(
1696
- "Another sponsor duty command is still running for this login. Wait for it to finish instead of starting overlapping watchers or job mutations.",
1697
- );
2122
+ // Short check-ins (the lease keeper, a pulse) hold the lock for well under
2123
+ // a second, so a colliding command waits briefly instead of failing.
2124
+ const waitUntil = Date.now() + SPONSOR_LOCK_WAIT_MS;
2125
+ for (;;) {
2126
+ try {
2127
+ handle = await fs.open(lockPath, "wx", 0o600);
2128
+ break;
2129
+ } catch (error) {
2130
+ if (error.code !== "EEXIST") throw error;
2131
+ const stale = await sponsorLockIsStale(lockPath);
2132
+ if (stale) {
2133
+ await fs.unlink(lockPath).catch(() => undefined);
2134
+ continue;
2135
+ }
2136
+ if (Date.now() >= waitUntil) {
2137
+ throw new Error(
2138
+ "Another sponsor duty command is still running for this login. Wait for it to finish instead of starting overlapping watchers or job mutations.",
2139
+ );
2140
+ }
2141
+ await new Promise((resolve) => setTimeout(resolve, 250));
1698
2142
  }
1699
- await fs.unlink(lockPath);
1700
- handle = await fs.open(lockPath, "wx", 0o600);
1701
2143
  }
1702
2144
  try {
1703
2145
  await handle.writeFile(
@@ -1751,6 +2193,7 @@ async function archiveSponsorState(options, state) {
1751
2193
 
1752
2194
  async function removeCompletedJob({ options, state, jobId }) {
1753
2195
  const jobState = requireJobState(state, jobId);
2196
+ stopJobLeaseKeeper(jobState);
1754
2197
  await scrubJobCredentials(jobState);
1755
2198
  const nextJobs = { ...(state.jobs || {}) };
1756
2199
  delete nextJobs[String(jobId)];
@@ -1765,6 +2208,7 @@ async function removeCompletedJob({ options, state, jobId }) {
1765
2208
 
1766
2209
  async function preserveFailedJobWorkspace({ options, state, jobId, reason }) {
1767
2210
  const jobState = requireJobState(state, jobId);
2211
+ stopJobLeaseKeeper(jobState);
1768
2212
  await scrubJobCredentials(jobState);
1769
2213
  const nextJobs = { ...(state.jobs || {}) };
1770
2214
  delete nextJobs[String(jobId)];
@@ -2192,6 +2636,13 @@ function sponsorJobUsage() {
2192
2636
  " lumine sponsor job relay-applied <job-id> <relay-id...>",
2193
2637
  " lumine sponsor job helper-start <job-id> [--ordinal <n>]",
2194
2638
  " lumine sponsor job helper-complete <job-id> --ordinal <n> --outcome <text> [--resolved-model <name>] [--resolved-effort <level>]",
2639
+ " lumine sponsor job sync-main <job-id>",
2640
+ " lumine sponsor job assets <job-id> upload <file...>",
2641
+ " lumine sponsor job hold <job-id>",
2642
+ " lumine sponsor job suggest <job-id> branch --note <message> | thumbnail",
2643
+ " lumine sponsor job thumbnail <job-id> set <file>",
2644
+ " lumine sponsor job rename <job-id> <title>",
2645
+ " lumine sponsor job describe <job-id> <text>",
2195
2646
  " lumine sponsor job complete <job-id> --summary <text> [--resolved-model <name>] [--resolved-effort <level>] [--resolved-service-tier <tier>]",
2196
2647
  " lumine sponsor job fail <job-id> --reason <text>",
2197
2648
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {