@stage5/lumine 0.3.6 → 0.3.10

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/lib/admin.js CHANGED
@@ -2112,11 +2112,74 @@ export function parseAdminOperation(options) {
2112
2112
  // reviews as the admin controls on the website quest page. Mikey's own
2113
2113
  // decision, any time, never in a daily run.
2114
2114
  const usage =
2115
- "Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
2115
+ "Usage: lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list|show|approve|reject|merge] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
2116
2116
  const note = String(options.note || "").trim();
2117
2117
  if (note.length > 1000) {
2118
2118
  throw cliValidationError("--note must be at most 1000 characters.");
2119
2119
  }
2120
+ if (action === "parent-reply") {
2121
+ // Answer a parent's question: shown on their page, and they are emailed.
2122
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2123
+ const memberId = parseRequiredInteger(extra, "Member (child) user ID", 1);
2124
+ if (!note) throw cliValidationError("parent-reply needs --note <your answer>.");
2125
+ return writeOperation(
2126
+ "meetup.parent-reply",
2127
+ "POST",
2128
+ `/cli/admin/meetup-quest/crews/${crewId}/parents/${memberId}/reply`,
2129
+ { reply: note },
2130
+ { requiresRun: false },
2131
+ );
2132
+ }
2133
+ if (action === "branches" || action === "branch") {
2134
+ // Every branch name a member types is registered; the first sighting alerts
2135
+ // Mikey (a Zero DM card). Approve = a real branch, reject = not real (the
2136
+ // members' branch text is cleared and their crew chat is told), merge =
2137
+ // same as an official branch (members are rewritten to its name).
2138
+ const branchUsage =
2139
+ "Usage: lumine admin meetup branches [list [--status pending|official|rejected|all]] | show <id> | approve <id> [--note <text>] | reject <id> [--note <text>] | merge <id> --into <officialId> [--note <text>].";
2140
+ const branchAction = target || "list";
2141
+ if (branchAction === "list") {
2142
+ return readOperation(
2143
+ "meetup.branches",
2144
+ withQuery("/cli/admin/meetup-quest/branches", {
2145
+ status: parseChoice(options.adminStatus || "pending", "--status", [
2146
+ "pending",
2147
+ "official",
2148
+ "rejected",
2149
+ "all",
2150
+ ]),
2151
+ }),
2152
+ { requiresRun: false },
2153
+ );
2154
+ }
2155
+ if (!["show", "approve", "reject", "merge"].includes(branchAction)) {
2156
+ throw cliValidationError(branchUsage);
2157
+ }
2158
+ const branchId = parseRequiredInteger(extra, "Branch ID", 1);
2159
+ if (branchAction === "show") {
2160
+ return readOperation(
2161
+ "meetup.branch.show",
2162
+ `/cli/admin/meetup-quest/branches/${branchId}`,
2163
+ { requiresRun: false },
2164
+ );
2165
+ }
2166
+ if (branchAction === "merge" && !options.adminInto) {
2167
+ throw cliValidationError(
2168
+ "lumine admin meetup branches merge <id> needs --into <officialBranchId> (see `branches list --status official`).",
2169
+ );
2170
+ }
2171
+ return writeOperation(
2172
+ `meetup.branch.${branchAction}`,
2173
+ "POST",
2174
+ `/cli/admin/meetup-quest/branches/${branchId}/review`,
2175
+ {
2176
+ action: branchAction === "approve" ? "official" : branchAction,
2177
+ mergeIntoId: branchAction === "merge" ? parseRequiredInteger(options.adminInto, "--into", 1) : 0,
2178
+ note,
2179
+ },
2180
+ { requiresRun: false },
2181
+ );
2182
+ }
2120
2183
  if (action === "story" || action === "stories") {
2121
2184
  // A crew's Bridge Builder story page: Mikey's final approval (the
2122
2185
  // admin bar on the story page does the same). Publishing copies only
@@ -2155,6 +2218,44 @@ export function parseAdminOperation(options) {
2155
2218
  { requiresRun: false },
2156
2219
  );
2157
2220
  }
2221
+ if (action === "emails") {
2222
+ // Parent emails held for the owner's review: list | send <id> | discard <id> | review on|off
2223
+ const sub = target || "list";
2224
+ if (sub === "list") {
2225
+ return readOperation("meetup.emails", "/cli/admin/meetup-quest/emails", { requiresRun: false });
2226
+ }
2227
+ if (sub === "review") {
2228
+ const on = extra === "on" ? true : extra === "off" ? false : null;
2229
+ if (on === null) throw cliValidationError("lumine admin meetup emails review on|off");
2230
+ return writeOperation("meetup.emails", "POST", "/cli/admin/meetup-quest/emails/review", { on }, { requiresRun: false });
2231
+ }
2232
+ if (sub === "send" || sub === "discard") {
2233
+ const id = parseRequiredInteger(extra, "Email ID", 1);
2234
+ return writeOperation("meetup.emails", "POST", `/cli/admin/meetup-quest/emails/${id}/${sub}`, {}, { requiresRun: false });
2235
+ }
2236
+ throw cliValidationError("lumine admin meetup emails [list | send <id> | discard <id> | review on|off]");
2237
+ }
2238
+ if (action === "friends") {
2239
+ const sub = target || "status";
2240
+ if (sub === "status") {
2241
+ return readOperation("meetup.friends", "/cli/admin/meetup-quest/friends-switch", { requiresRun: false });
2242
+ }
2243
+ if (sub === "on" || sub === "off") {
2244
+ return writeOperation("meetup.friends", "POST", "/cli/admin/meetup-quest/friends-switch", { on: sub === "on" }, { requiresRun: false });
2245
+ }
2246
+ throw cliValidationError("lumine admin meetup friends [status | on | off]");
2247
+ }
2248
+ if (action === "funnel") {
2249
+ // Early-funnel views + where active crews are stuck (for Claude's own
2250
+ // analysis; no owner view).
2251
+ return readOperation(
2252
+ "meetup.funnel",
2253
+ withQuery("/cli/admin/meetup-quest/funnel", {
2254
+ days: parseRequiredInteger(options.adminDays || "14", "--days", 1, 90),
2255
+ }),
2256
+ { requiresRun: false },
2257
+ );
2258
+ }
2158
2259
  if (!action || action === "list") {
2159
2260
  return readOperation(
2160
2261
  "meetup.list",
@@ -2176,6 +2277,18 @@ export function parseAdminOperation(options) {
2176
2277
  { requiresRun: false },
2177
2278
  );
2178
2279
  }
2280
+ if (action === "approve-crew" || action === "approve-grownup") {
2281
+ // Every step passes through the owner: the crew (members + branches) and
2282
+ // the grown-up step (parents' yes + the named adult).
2283
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2284
+ return writeOperation(
2285
+ action === "approve-crew" ? "meetup.approve-crew" : "meetup.approve-grownup",
2286
+ "POST",
2287
+ `/cli/admin/meetup-quest/crews/${crewId}/${action}`,
2288
+ {},
2289
+ { requiresRun: false },
2290
+ );
2291
+ }
2179
2292
  if (action === "approve-plan") {
2180
2293
  const crewId = parseRequiredInteger(target, "Crew ID", 1);
2181
2294
  return writeOperation(
@@ -5133,6 +5246,11 @@ function printMeetupCrew(crew) {
5133
5246
  console.log(` ${step.number}. ${step.label} [${step.state}] ${step.detail}`);
5134
5247
  }
5135
5248
  console.log(` Members: ${formatMeetupMembers(crew.members)}`);
5249
+ for (const parent of crew.parentContacts || []) {
5250
+ console.log(
5251
+ ` Parent of ${parent.childUsername}: ${parent.status}${parent.verifiedGuardian ? " (guardian on file)" : parent.staffVerified ? " (staff verified)" : " (child-typed address: NOT verified)"} · ${parent.email}${parent.question ? ` · asks: "${parent.question}"` : ""}`,
5252
+ );
5253
+ }
5136
5254
  if (Array.isArray(crew.invites) && crew.invites.length) {
5137
5255
  console.log(
5138
5256
  ` Pending invites: ${crew.invites.map((invite) => `${invite.username} (${invite.userId}, invite ${invite.inviteId})`).join("; ")}`,
@@ -5146,7 +5264,7 @@ function printMeetupCrew(crew) {
5146
5264
  const plan = crew.plan || {};
5147
5265
  if (plan.status && plan.status !== "none") {
5148
5266
  console.log(
5149
- ` Plan (${plan.status}): ${plan.date} in ${plan.area}: ${plan.activity}${plan.note ? ` · note: ${plan.note}` : ""}`,
5267
+ ` Plan (${plan.status}): ${plan.date}${plan.time ? ` at ${plan.time}` : ""} in ${plan.area}: ${plan.activity}${plan.note ? ` · note: ${plan.note}` : ""}`,
5150
5268
  );
5151
5269
  }
5152
5270
  if (crew.planApprovedBy?.username) {
@@ -5214,6 +5332,54 @@ function printMeetupResult({ operation, data, result }) {
5214
5332
  }
5215
5333
  return;
5216
5334
  }
5335
+ if (operation.name === "meetup.branches") {
5336
+ const branches = Array.isArray(data.branches) ? data.branches : [];
5337
+ console.log(`${branches.length} branch name(s) (${data.filter || "pending"}):`);
5338
+ for (const branch of branches) {
5339
+ console.log(
5340
+ ` #${branch.id} · ${branch.status}${branch.mergedIntoId ? ` → #${branch.mergedIntoId}` : ""} · "${branch.name}" · ${branch.members} member(s) · first entered by ${branch.firstUsername || "?"}${branch.firstCrewId ? ` in crew #${branch.firstCrewId}` : ""}`,
5341
+ );
5342
+ }
5343
+ if (branches.some((branch) => branch.status === "pending")) {
5344
+ console.log("Decide: lumine admin meetup branches approve <id> | reject <id> | merge <id> --into <officialId>.");
5345
+ }
5346
+ return;
5347
+ }
5348
+ if (operation.name === "meetup.branch.show" || String(operation.name).startsWith("meetup.branch.")) {
5349
+ const branch = data.branch || {};
5350
+ console.log(
5351
+ `Branch #${branch.id} "${branch.name}" is now ${branch.status}${branch.mergedIntoName ? ` (merged into "${branch.mergedIntoName}")` : ""} · ${branch.members} member(s) use it · first entered by ${branch.firstUsername || "?"}.`,
5352
+ );
5353
+ return;
5354
+ }
5355
+ if (operation.name === "meetup.emails") {
5356
+ console.log(`Parent email review: ${data.review ? "ON (emails wait for you)" : "OFF (emails go out at once)"}`);
5357
+ const open = (data.drafts || []).filter((d) => d.status === "held" || d.status === "failed");
5358
+ if (!open.length) console.log("Nothing is waiting.");
5359
+ for (const d of open) {
5360
+ console.log(`\n#${d.id} [${d.status}] crew #${d.crewId} ${d.kind === "brief" ? "meetup request" : "reply"} -> ${d.to}`);
5361
+ console.log(` Subject: ${d.subject}`);
5362
+ console.log(` ${String(d.body).split("\n").join("\n ")}`);
5363
+ }
5364
+ return;
5365
+ }
5366
+ if (operation.name === "meetup.friends") {
5367
+ console.log(`Friend buttons are ${data.on ? "ON" : "OFF"}.`);
5368
+ return;
5369
+ }
5370
+ if (operation.name === "meetup.funnel") {
5371
+ console.log(`Bridge Builder funnel, last ${data.windowDays} day(s)${data.trackingLive ? "" : " (view tracking NOT live yet)"}:`);
5372
+ for (const [kind, v] of Object.entries(data.views || {})) {
5373
+ console.log(` ${kind}: ${v.users} people, ${v.hits} visits`);
5374
+ }
5375
+ console.log(` quest page viewers: ${data.questPage?.viewers ?? 0}, of whom ever in a crew: ${data.questPage?.viewersEverInCrew ?? 0}`);
5376
+ console.log(` crews by status: ${JSON.stringify(data.crews?.byStatus || {})}; active by step: ${JSON.stringify(data.crews?.activeByStep || {})}; active by size: ${JSON.stringify(data.crews?.activeBySize || {})}`);
5377
+ if (data.branches) console.log(` branch names: ${JSON.stringify(data.branches.byStatus)}${data.branches.oldestPendingHours ? `; oldest pending ${data.branches.oldestPendingHours}h` : ""}`);
5378
+ for (const crew of data.activeCrews || []) {
5379
+ console.log(` #${crew.crewId} step ${crew.step} · ${crew.members} member(s) · idle ${crew.daysIdle}d · ${crew.unverifiedBranches ? `${crew.unverifiedBranches} branch(es) to verify · ` : ""}${crew.parents && crew.parents.asked ? `parents ${crew.parents.approved}/${crew.parents.asked} · ` : ""}chat7d ${crew.chat7d ? `${crew.chat7d.messages} msg/${crew.chat7d.speakers} people` : "n/a"} · ${crew.blocking}`);
5380
+ }
5381
+ return;
5382
+ }
5217
5383
  if (operation.name === "meetup.list") {
5218
5384
  const crews = Array.isArray(data.crews) ? data.crews : [];
5219
5385
  console.log(`${crews.length} meetup crew(s) (${data.filter || "review"}):`);
@@ -5224,12 +5390,12 @@ function printMeetupResult({ operation, data, result }) {
5224
5390
  }
5225
5391
  if (crews.some((crew) => crew.waitingFor)) {
5226
5392
  console.log(
5227
- "Review: lumine admin meetup show <crewId>, then approve-plan | send-back --note | approve --attended <ids>.",
5393
+ "Review: lumine admin meetup show <crewId>, then approve-crew | approve-plan | approve-grownup | send-back --note | approve --attended <ids> (classroom plans: approve at the headmaster desk with a room and times).",
5228
5394
  );
5229
5395
  }
5230
5396
  return;
5231
5397
  }
5232
- if (operation.name === "meetup.show") {
5398
+ if (operation.name === "meetup.show" || operation.name === "meetup.parent-reply") {
5233
5399
  printMeetupCrew(data.crew || {});
5234
5400
  return;
5235
5401
  }
@@ -5250,11 +5416,15 @@ function printMeetupResult({ operation, data, result }) {
5250
5416
  return;
5251
5417
  }
5252
5418
  const stage = data.result?.stage === "video" ? "video" : "plan";
5253
- if (operation.name === "meetup.approve-plan") {
5419
+ if (operation.name === "meetup.approve-crew" || operation.name === "meetup.approve-grownup") {
5420
+ console.log(
5421
+ `${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}'s ${operation.name === "meetup.approve-crew" ? "crew step" : "grown-up step"}.`,
5422
+ );
5423
+ } else if (operation.name === "meetup.approve-plan") {
5254
5424
  console.log(
5255
5425
  result?.changed === false
5256
5426
  ? `Crew #${data.result?.crewId}'s plan was already approved.`
5257
- : `Approved crew #${data.result?.crewId}'s plan. They can meet up and film it now.`,
5427
+ : `Approved crew #${data.result?.crewId}'s plan. Next the crew gets its parents' yes and names a grown-up.`,
5258
5428
  );
5259
5429
  } else if (operation.name === "meetup.send-back") {
5260
5430
  console.log(`Sent crew #${data.result?.crewId}'s ${stage} back with your note.`);
@@ -5262,7 +5432,7 @@ function printMeetupResult({ operation, data, result }) {
5262
5432
  const unlocked = data.unlocks?.unlockedUserIds || [];
5263
5433
  const failed = data.unlocks?.failed || [];
5264
5434
  console.log(
5265
- `${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}: unlocked for user(s) ${unlocked.join(", ") || "none"}.${failed.length ? ` Unlock FAILED for ${failed.join(", ")}; run the same command again.` : ""}`,
5435
+ `${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}${data.result?.tier ? ` at ${data.result.tier} tier` : ""}: unlocked for user(s) ${unlocked.join(", ") || "none"}.${failed.length ? ` Unlock FAILED for ${failed.join(", ")}; run the same command again.` : ""}`,
5266
5436
  );
5267
5437
  }
5268
5438
  if (data.crew) printMeetupCrew(data.crew);
package/lib/commands.js CHANGED
@@ -2609,6 +2609,7 @@ export function parseArgs(args) {
2609
2609
  storageSize: raw.size ? String(raw.size) : "",
2610
2610
  adminAttended: raw.attended ? String(raw.attended) : "",
2611
2611
  adminConfirm: raw.confirm === undefined ? "" : String(raw.confirm),
2612
+ adminInto: raw.into === undefined ? "" : String(raw.into),
2612
2613
  adminNoAnnounce: Boolean(raw.noAnnounce),
2613
2614
  adminContextLimit: raw.limit === undefined ? 20 : Number(raw.limit),
2614
2615
  adminRun: raw.run ? String(raw.run) : "",
@@ -3015,7 +3016,7 @@ export function printHelp() {
3015
3016
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3016
3017
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3017
3018
  lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
3018
- lumine admin meetup list [--status review|active|completed|all] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3019
+ lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3019
3020
  lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
3020
3021
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3021
3022
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
@@ -60,8 +60,10 @@ export async function sponsorDutyCommand(options, args, commandServices) {
60
60
  return;
61
61
  }
62
62
  if (action === "watch") {
63
- await withSponsorStateLock(options, () =>
64
- watchDuty(options, commandServices),
63
+ await withSponsorStateLock(
64
+ options,
65
+ () => watchDuty(options, commandServices),
66
+ "watch",
65
67
  );
66
68
  return;
67
69
  }
@@ -162,6 +164,17 @@ export async function sponsorJobCommand(options, args, commandServices) {
162
164
  );
163
165
  return;
164
166
  }
167
+ if (action === "release") {
168
+ const positionalReason = args.slice(2).join(" ").trim();
169
+ await withSponsorStateLock(options, () =>
170
+ releaseJob(
171
+ options,
172
+ jobId,
173
+ options.sponsorFailureReason || positionalReason,
174
+ ),
175
+ );
176
+ return;
177
+ }
165
178
  if (action === "fail") {
166
179
  const positionalReason = args.slice(2).join(" ").trim();
167
180
  await withSponsorStateLock(options, () =>
@@ -1396,6 +1409,35 @@ async function completeJob(options, jobId, commandServices) {
1396
1409
  ]);
1397
1410
  }
1398
1411
 
1412
+ // Hands the job back to the pool instead of failing the user's request:
1413
+ // another live session picks it up. Use it when this session should not
1414
+ // (or cannot) do the job; `fail` is only for work that genuinely cannot be done.
1415
+ async function releaseJob(options, jobId, rawReason) {
1416
+ const reason = String(rawReason || "").trim();
1417
+ if (!reason) {
1418
+ throw new Error("Say why this session is handing the job back with --reason <text>.");
1419
+ }
1420
+ const auth = await ensureSponsorAuth(options);
1421
+ let state = await loadOwnedState({ options, auth });
1422
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
1423
+ const jobState = requireJobState(state, jobId);
1424
+ const result = await retrySponsorTransport(() =>
1425
+ sponsorJobRequest({
1426
+ options,
1427
+ auth,
1428
+ state,
1429
+ jobState,
1430
+ path: "/release",
1431
+ body: { reason: reason.slice(0, 1000) },
1432
+ }),
1433
+ );
1434
+ await removeCompletedJob({ options, state, jobId });
1435
+ printJsonOrLines(options, result, [
1436
+ `Workshop job #${jobId} is back in the queue for another on-duty session. The user's request is still open.`,
1437
+ "This session will not claim it again.",
1438
+ ]);
1439
+ }
1440
+
1399
1441
  async function failJob(options, jobId, rawReason) {
1400
1442
  const reason = String(rawReason || "").trim();
1401
1443
  if (!reason) {
@@ -1539,8 +1581,10 @@ async function recordClaim({ options, state, claim }) {
1539
1581
  "Twinkle returned a Workshop runtime that does not match this live agent session.",
1540
1582
  );
1541
1583
  }
1584
+ const jobRoot = sponsorJobRoot(options);
1585
+ await fs.mkdir(jobRoot, { recursive: true, mode: 0o700 });
1542
1586
  const tempDir = await fs.mkdtemp(
1543
- path.join(os.tmpdir(), `lumine-${persona}-job-${jobId}-`),
1587
+ path.join(jobRoot, `lumine-${persona}-job-${jobId}-`),
1544
1588
  );
1545
1589
  await fs.chmod(tempDir, 0o700);
1546
1590
  const jobState = {
@@ -2127,14 +2171,24 @@ async function tryArchiveInvalidSponsorStateForStop(options) {
2127
2171
  }
2128
2172
  }
2129
2173
 
2130
- async function withSponsorStateLock(options, operation) {
2174
+ // A session's own `duty watch` holds this lock for up to a full watch window,
2175
+ // so job commands wait that long instead of failing mid-job; a second watch
2176
+ // still fails fast.
2177
+ function sponsorLockWaitMs(kind) {
2178
+ return kind === "watch"
2179
+ ? SPONSOR_LOCK_WAIT_MS
2180
+ : MAX_DUTY_WATCH_MS + DUTY_WATCH_DEADLINE_GRACE_MS + 5_000;
2181
+ }
2182
+
2183
+ async function withSponsorStateLock(options, operation, kind = "command") {
2131
2184
  const lockPath = `${sponsorDutyStatePath(options)}.lock`;
2132
2185
  const lockDirectory = path.dirname(lockPath);
2133
2186
  await fs.mkdir(lockDirectory, { recursive: true, mode: 0o700 });
2134
2187
  let handle;
2135
2188
  // Short check-ins (the lease keeper, a pulse) hold the lock for well under
2136
2189
  // a second, so a colliding command waits briefly instead of failing.
2137
- const waitUntil = Date.now() + SPONSOR_LOCK_WAIT_MS;
2190
+ const waitUntil = Date.now() + sponsorLockWaitMs(kind);
2191
+ let toldWaiting = false;
2138
2192
  for (;;) {
2139
2193
  try {
2140
2194
  handle = await fs.open(lockPath, "wx", 0o600);
@@ -2151,6 +2205,12 @@ async function withSponsorStateLock(options, operation) {
2151
2205
  "Another sponsor duty command is still running for this login. Wait for it to finish instead of starting overlapping watchers or job mutations.",
2152
2206
  );
2153
2207
  }
2208
+ if (!toldWaiting && kind !== "watch") {
2209
+ toldWaiting = true;
2210
+ console.error(
2211
+ "lumine: waiting for this session's duty watch to finish its check-in…",
2212
+ );
2213
+ }
2154
2214
  await new Promise((resolve) => setTimeout(resolve, 250));
2155
2215
  }
2156
2216
  }
@@ -2305,11 +2365,26 @@ async function scrubPreservedWorkspaceCredential(preserved) {
2305
2365
  preserved.credentialsRemovedAt = credentialState.credentialsRemovedAt;
2306
2366
  }
2307
2367
 
2368
+ // Job workspaces live next to the CLI's auth file, not in the OS temp
2369
+ // directory: a reboot or crash there wipes unsaved Workshop work, and agents
2370
+ // on duty may be barred from writing to temp directories at all.
2371
+ const SPONSOR_JOB_ROOT_NAME = "lumine-sponsor-jobs";
2372
+
2373
+ function sponsorJobRoot(options) {
2374
+ return path.join(
2375
+ path.dirname(path.resolve(options.authFile)),
2376
+ SPONSOR_JOB_ROOT_NAME,
2377
+ );
2378
+ }
2379
+
2308
2380
  function resolveJobTempDir(jobState) {
2309
2381
  const tempRoot = path.resolve(os.tmpdir());
2310
2382
  const tempDir = path.resolve(String(jobState?.tempDir || ""));
2383
+ // Jobs claimed by older CLI versions still sit in the OS temp directory.
2384
+ const parent = path.dirname(tempDir);
2311
2385
  if (
2312
- !tempDir.startsWith(`${tempRoot}${path.sep}`) ||
2386
+ (path.basename(parent) !== SPONSOR_JOB_ROOT_NAME &&
2387
+ !tempDir.startsWith(`${tempRoot}${path.sep}`)) ||
2313
2388
  !path.basename(tempDir).startsWith("lumine-") ||
2314
2389
  !path.basename(tempDir).includes("-job-")
2315
2390
  ) {
@@ -2657,6 +2732,7 @@ function sponsorJobUsage() {
2657
2732
  " lumine sponsor job rename <job-id> <title>",
2658
2733
  " lumine sponsor job describe <job-id> <text>",
2659
2734
  " lumine sponsor job complete <job-id> --summary <text> [--resolved-model <name>] [--resolved-effort <level>] [--resolved-service-tier <tier>]",
2660
- " lumine sponsor job fail <job-id> --reason <text>",
2735
+ " lumine sponsor job release <job-id> --reason <text> (hand back to the pool; the request stays open)",
2736
+ " lumine sponsor job fail <job-id> --reason <text> (ends the user's request)",
2661
2737
  ].join("\n");
2662
2738
  }
package/lib/sponsor.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import readline from "node:readline/promises";
2
3
  import { stdin as input, stdout as output } from "node:process";
3
4
 
4
5
  import { ensureAuth } from "./auth.js";
5
6
  import {
6
7
  sponsorDutyCommand,
8
+ sponsorDutyStatePath,
7
9
  sponsorJobCommand,
8
10
  } from "./sponsor-duty.js";
9
11
  import { requestJson } from "./http.js";
@@ -59,7 +61,7 @@ export async function sponsorCommand(options, commandServices) {
59
61
  auth,
60
62
  path: `/jobs?limit=${encodeURIComponent(String(options.limit || 20))}`,
61
63
  });
62
- printSponsorJobs(result, options);
64
+ printSponsorJobs(result, options, localDutyId(options));
63
65
  return;
64
66
  }
65
67
  if (area === "status") {
@@ -308,9 +310,37 @@ function printSponsorAgreement(agreement, options) {
308
310
  for (const line of agreement.disclosure || []) console.log(`- ${line}`);
309
311
  }
310
312
 
311
- function printSponsorJobs(jobs, options) {
313
+ // This session's own duty, so the list can say which jobs are its own.
314
+ function localDutyId(options) {
315
+ try {
316
+ const state = JSON.parse(readFileSync(sponsorDutyStatePath(options), "utf8"));
317
+ return Number(state?.duty?.id || 0) || null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ function describeJobHolder(job, ownDutyId) {
324
+ const holder = Number(job.heldByDutySessionId || 0) || null;
325
+ if (!holder) return null;
326
+ return holder === ownDutyId
327
+ ? `held by duty #${holder} (this session)`
328
+ : `held by duty #${holder} (another session; not yours)`;
329
+ }
330
+
331
+ function printSponsorJobs(jobs, options, ownDutyId = null) {
312
332
  if (options.json) {
313
- console.log(JSON.stringify(jobs, null, 2));
333
+ console.log(
334
+ JSON.stringify(
335
+ (Array.isArray(jobs) ? jobs : []).map((job) => ({
336
+ ...job,
337
+ heldByThisSession:
338
+ !!ownDutyId && Number(job.heldByDutySessionId) === ownDutyId,
339
+ })),
340
+ null,
341
+ 2,
342
+ ),
343
+ );
314
344
  return;
315
345
  }
316
346
  if (!Array.isArray(jobs) || jobs.length === 0) {
@@ -318,8 +348,10 @@ function printSponsorJobs(jobs, options) {
318
348
  return;
319
349
  }
320
350
  for (const job of jobs) {
351
+ const target = job.targetBuild || job.contributionBuild;
352
+ const holder = describeJobHolder(job, ownDutyId);
321
353
  console.log(
322
- `#${job.id} ${job.status} · ${displayPersona(job.persona)} · ${job.requester?.username || `user ${job.requester?.userId}`} · ${job.contributionBuild?.title || `Build ${job.contributionBuild?.id}`}`,
354
+ `#${job.id} ${job.status}${holder ? ` (${holder})` : ""} · ${displayPersona(job.persona)} · ${job.requester?.username || `user ${job.requester?.userId}`} · ${target?.title || `Build ${target?.id}`}`,
323
355
  );
324
356
  }
325
357
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.6",
3
+ "version": "0.3.10",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-10-03T04:47:01.454Z
5
+ Generated: 2026-10-03T09:02:05.836Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -552,16 +552,18 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
552
552
  - Only one music generation per viewer may run at a time (code ai_music_generation_in_progress). A retry with the same requestId returns the finished result without paying again (replayed: true), or code music_in_progress while it is still being made.
553
553
  - Generation usually takes one to three minutes; the SDK timeout defaults to 600000ms. Show progress UI while waiting.
554
554
  - Example: const song = await Twinkle.ai.generateMusic({ prompt: 'Warm lo-fi hip hop with dusty drums, a mellow Rhodes and rain in the background', instrumental: true }); audio.src = song.url;
555
- - async generateSpeech({ text, voice, language, requestId, timeoutMs } = {}) | scopes: none
555
+ - async generateSpeech({ text, voice, style, language, requestId, timeoutMs } = {}) | scopes: none
556
556
  - Returns: { success, asset, url, mimeType, durationSeconds, alignment, text, voice, model, requestId, replayed?, aiUsagePolicy }
557
- - Generate spoken audio with Eleven v4 and save it in the viewer's Twinkle.files. New generations use AI Battery.
557
+ - Generate spoken audio (OpenAI realtime voices or ElevenLabs presets) and save it in the viewer's Twinkle.files. New generations use AI Battery.
558
558
  - Signed-in viewers only. Each new generation uses AI Battery; show this before the viewer starts voice interaction.
559
- - text: 1–2,000 characters. voice: warm (default), bright, or calm. language: optional ISO language code such as en.
559
+ - text: 1–2,000 characters. language: optional ISO language code such as en.
560
+ - voice: an OpenAI realtime voice (alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar) or an ElevenLabs preset (warm, bright, calm; warm is the default). For a recurring character, pick one voice and keep it everywhere, including any pre-recorded lines, so the character always sounds the same.
561
+ - style (OpenAI voices only): up to 300 characters of performance direction such as age, mood, pace and clarity. The voice reads the text word for word in that style.
560
562
  - The MP3 is saved in the viewer's Twinkle.files and counts toward storage. Replay the saved URL to listen again without another AI request.
561
- - alignment contains provider character timing for subtitles or animation. Respect Twinkle tab mute and browser playback controls.
563
+ - alignment contains provider character timing for subtitles or animation (ElevenLabs presets only; null for OpenAI voices). Respect Twinkle tab mute and browser playback controls.
562
564
  - Reuse requestId after a lost response. Completed audio replays the saved asset and settles the same billing receipt without regeneration or duplicate charges.
563
565
  - Only one speech or transcription request per viewer runs at a time. SDK timeout defaults to 180000ms.
564
- - Example: const speech = await Twinkle.ai.generateSpeech({ text: 'Welcome to our club!', voice: 'warm', language: 'en' }); audio.src = speech.url;
566
+ - Example: const speech = await Twinkle.ai.generateSpeech({ text: 'Welcome to our club!', voice: 'shimmer', style: 'Cheerful, curious thirteen-year-old girl; bright and warm.', language: 'en' }); audio.src = speech.url;
565
567
  - async transcribeAudio({ audioBase64, language, requestId, timeoutMs } = {}) | scopes: none
566
568
  - Returns: { success, text, words: [{ text, start, end }], language, durationSeconds, model, requestId, replayed?, aiUsagePolicy }
567
569
  - Recognize a short recording with ElevenLabs Scribe v2. New recognition requests use AI Battery.
@@ -1498,7 +1500,7 @@ world.updatePresence({ x, y, z, facing });
1498
1500
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1499
1501
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1500
1502
  - Verify a bounded batch of inputs for an approved server-simulated climb.
1501
- - Only for completionProof: classic-tower-v1. Send 1 to 600 chronological physics frames, each [dt, moveX, moveY, cameraForwardX, cameraForwardZ, jumpPressed, jumpHeld, speedMultiplier]. dt is in seconds, at most 0.05; movement axes are -1 through 1; the camera values are the horizontal components before normalization; jump flags are 0 or 1; speedMultiplier is an existing Classic Tower trail speed (1, 1.1, 1.2, 1.3, 1.35 or 1.4). Geometry and player state are owned by the registered server simulation.
1503
+ - Only for completionProof: classic-tower-v1. Send 1 to 600 chronological physics frames, each [dt, moveX, moveY, cameraForwardX, cameraForwardZ, jumpPressed, jumpHeld, speedMultiplier]. dt is in seconds, at most 0.05; movement axes are -1 through 1; the camera values are the horizontal components before normalization; jump flags are 0 or 1; speedMultiplier is an existing Classic Tower trail speed (1, 1.1, 1.2, 1.3, 1.35, 1.4, 1.6 or 2.2). Geometry and player state are owned by the registered server simulation.
1502
1504
  - Send occasional batches with at most one request in flight. Keep the previous token and the exact batch until a response confirms it; an identical retry is safe. Use the returned token for the next batch. Simulation time cannot outrun wall time. A completed token is evidence of a legal simulated run, not proof that a human played or that inputs were not automated.
1503
1505
  - On respawn or a return from another world, begin a fresh run at the canonical spawn via start. Preserve other worlds and gameplay. Do not submit positions, scores, secret keys, or a client completion flag. Preview tokens can never be redeemed in the published app.
1504
1506
  - For breadface-v1, breadface-v2 and breadface-v3, send 1–1,000 chronological [dt, inputBits] frames (dt > 0 and <= 0.033). Bits are left=1, right=2, jumpHeld=4, jumpQueued=8, fireHeld=16. The server replays the registered frozen game; only a legitimate goal and any rule-specific bonus qualify. Honor maxFrames and retain unacknowledged inputs for a bounded retry.