backeer-opencode 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,6 +77,43 @@ function createConsoleReporter(levelValue) {
77
77
 
78
78
  // src/config.js
79
79
  var DEFAULT_ORIGIN = "https://external-gateway.backeer.com";
80
+ function integer(value, fallback, { minimum = 0, maximum = 86400 } = {}) {
81
+ if (value === void 0 || value === null || value === "") return fallback;
82
+ const parsed = Number(value);
83
+ if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
84
+ throw new Error(`Expected an integer between ${minimum} and ${maximum}`);
85
+ }
86
+ return parsed;
87
+ }
88
+ function loadLifecycleConfig(option) {
89
+ const values = option && typeof option === "object" ? option : {};
90
+ const masterValue = typeof option === "boolean" ? option : values.enabled ?? process.env.BACKEER_NOTIFY_LIFECYCLE;
91
+ const masterEnabled = enabled(masterValue);
92
+ return {
93
+ enabled: masterEnabled,
94
+ completed: masterEnabled && enabled(
95
+ values.completed ?? process.env.BACKEER_NOTIFY_SESSION_COMPLETED
96
+ ),
97
+ errors: masterEnabled && enabled(
98
+ values.errors ?? process.env.BACKEER_NOTIFY_SESSION_ERRORS
99
+ ),
100
+ retries: masterEnabled && enabled(
101
+ values.retries ?? process.env.BACKEER_NOTIFY_SESSION_RETRIES
102
+ ),
103
+ updates: masterEnabled && enabled(
104
+ values.updates ?? process.env.BACKEER_NOTIFY_OPENCODE_UPDATES
105
+ ),
106
+ completionMinSeconds: integer(
107
+ values.completionMinSeconds ?? process.env.BACKEER_SESSION_COMPLETION_MIN_SECONDS,
108
+ 0
109
+ ),
110
+ retryAttemptThreshold: integer(
111
+ values.retryAttemptThreshold ?? process.env.BACKEER_RETRY_ATTEMPT_THRESHOLD,
112
+ 3,
113
+ { minimum: 1, maximum: 100 }
114
+ )
115
+ };
116
+ }
80
117
  function defaultConfigDirectory() {
81
118
  const base = process.env.XDG_CONFIG_HOME?.trim();
82
119
  return base ? join(base, "opencode") : join(homedir(), ".config", "opencode");
@@ -114,6 +151,7 @@ function loadConfig(options = {}) {
114
151
  options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS
115
152
  ),
116
153
  todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
154
+ lifecycle: loadLifecycleConfig(options.lifecycle),
117
155
  logLevel: normalizeLogLevel(
118
156
  options.logLevel ?? process.env.BACKEER_LOG_LEVEL
119
157
  )
@@ -357,8 +395,130 @@ import {
357
395
  writeFileSync
358
396
  } from "node:fs";
359
397
  import { dirname, resolve } from "node:path";
360
- var PLUGIN_VERSION = "0.1.4";
361
- var COMMAND_DESCRIPTION = "Show Backeer status or manage its token: /backeer [status|token|token-file PATH]";
398
+
399
+ // src/updates.js
400
+ var PLUGIN_VERSION = "0.2.0";
401
+ var PACKAGE_NAME = "backeer-opencode";
402
+ var NPM_REGISTRY_URL = "https://registry.npmjs.org";
403
+ var CHECK_TIMEOUT_MS = 3e3;
404
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
405
+ function parseVersion(value) {
406
+ const match = String(value).trim().match(
407
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
408
+ );
409
+ if (!match) return;
410
+ return {
411
+ parts: [Number(match[1]), Number(match[2]), Number(match[3])],
412
+ prerelease: match[4]
413
+ };
414
+ }
415
+ function isVersionNewer(latestValue, currentValue) {
416
+ const latest = parseVersion(latestValue);
417
+ const current = parseVersion(currentValue);
418
+ if (!latest || !current) return false;
419
+ for (let index = 0; index < 3; index++) {
420
+ if (latest.parts[index] > current.parts[index]) return true;
421
+ if (latest.parts[index] < current.parts[index]) return false;
422
+ }
423
+ if (!latest.prerelease && current.prerelease) return true;
424
+ if (latest.prerelease && !current.prerelease) return false;
425
+ return Boolean(
426
+ latest.prerelease && current.prerelease && latest.prerelease !== current.prerelease
427
+ );
428
+ }
429
+ function createUpdateChecker({
430
+ fetchImplementation = globalThis.fetch,
431
+ now = () => Date.now()
432
+ } = {}) {
433
+ let checkedAt = 0;
434
+ let cachedResult;
435
+ let pendingCheck;
436
+ async function fetchLatest() {
437
+ const controller = new AbortController();
438
+ const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
439
+ try {
440
+ const response = await fetchImplementation(
441
+ `${NPM_REGISTRY_URL}/${PACKAGE_NAME}/latest`,
442
+ { signal: controller.signal }
443
+ );
444
+ if (!response.ok) return { state: "unavailable", currentVersion: PLUGIN_VERSION };
445
+ const data = await response.json();
446
+ const latestVersion = typeof data?.version === "string" ? data.version.trim() : "";
447
+ if (!parseVersion(latestVersion)) {
448
+ return { state: "unavailable", currentVersion: PLUGIN_VERSION };
449
+ }
450
+ if (!isVersionNewer(latestVersion, PLUGIN_VERSION)) {
451
+ return {
452
+ state: "current",
453
+ currentVersion: PLUGIN_VERSION,
454
+ latestVersion
455
+ };
456
+ }
457
+ return {
458
+ state: "available",
459
+ currentVersion: PLUGIN_VERSION,
460
+ latestVersion,
461
+ updateCommand: `opencode plugin ${PACKAGE_NAME}@${latestVersion} -g --force`
462
+ };
463
+ } catch {
464
+ return { state: "unavailable", currentVersion: PLUGIN_VERSION };
465
+ } finally {
466
+ clearTimeout(timeout);
467
+ }
468
+ }
469
+ return {
470
+ async check({ force = false } = {}) {
471
+ if (!force && checkedAt && now() - checkedAt < CHECK_INTERVAL_MS) {
472
+ return cachedResult;
473
+ }
474
+ if (pendingCheck) return pendingCheck;
475
+ pendingCheck = fetchLatest().then((result) => {
476
+ cachedResult = result;
477
+ checkedAt = now();
478
+ return result;
479
+ }).finally(() => {
480
+ pendingCheck = void 0;
481
+ });
482
+ return pendingCheck;
483
+ }
484
+ };
485
+ }
486
+ function formatAgentUpdateInstruction(info) {
487
+ return [
488
+ "[BACKEER UPDATE REQUESTED]",
489
+ `The user explicitly requested the Backeer OpenCode plugin update from v${info.currentVersion} to v${info.latestVersion}.`,
490
+ `Run exactly: ${info.updateCommand}`,
491
+ "Then verify that the global OpenCode config is pinned to the exact new version and that the matching package exists in the OpenCode cache.",
492
+ "Do not read, print, replace, or otherwise expose the Backeer token.",
493
+ "Do not use @latest and do not claim success until both installation and verification pass.",
494
+ "Do not terminate the active OpenCode process; tell the user to restart OpenCode after success."
495
+ ].join("\n");
496
+ }
497
+ function createUpdateNoticeHook(updateChecker) {
498
+ const notifiedSessions = /* @__PURE__ */ new Set();
499
+ return async (input, output) => {
500
+ if (notifiedSessions.has(input.sessionID)) return;
501
+ notifiedSessions.add(input.sessionID);
502
+ const info = await updateChecker.check();
503
+ if (info.state !== "available") return;
504
+ output.parts.push({
505
+ id: `prt_backeer-update-${Date.now()}`,
506
+ sessionID: input.sessionID,
507
+ messageID: output.message.id,
508
+ type: "text",
509
+ synthetic: true,
510
+ text: [
511
+ "[BACKEER UPDATE AVAILABLE]",
512
+ `Backeer OpenCode plugin v${info.latestVersion} is available; current version is v${info.currentVersion}.`,
513
+ "Inform the user that an update is available and suggest `/backeer update`.",
514
+ "Do not install it solely because of this notice."
515
+ ].join("\n")
516
+ });
517
+ };
518
+ }
519
+
520
+ // src/management.js
521
+ var COMMAND_DESCRIPTION = "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]";
362
522
  function tokenFileState(path) {
363
523
  try {
364
524
  const stats = statSync(path);
@@ -418,19 +578,35 @@ async function probeGateway(config) {
418
578
  clearTimeout(timeout);
419
579
  }
420
580
  }
421
- async function buildStatus(config, permissionController) {
422
- const gateway = await probeGateway(config);
581
+ function updateStatus(info) {
582
+ if (info.state === "available") {
583
+ return `v${info.latestVersion} available \u2014 use /backeer update`;
584
+ }
585
+ if (info.state === "current") return `current (v${info.currentVersion})`;
586
+ return "check unavailable";
587
+ }
588
+ async function buildStatus(config, permissionController, updateChecker) {
589
+ const [gateway, update] = await Promise.all([
590
+ probeGateway(config),
591
+ updateChecker.check()
592
+ ]);
423
593
  const permissionStatus = permissionController.status();
424
594
  return [
425
595
  "## Backeer status",
426
596
  "",
427
597
  `- Plugin version: ${PLUGIN_VERSION}`,
598
+ `- Update: ${updateStatus(update)}`,
428
599
  `- Gateway: ${config.origin.origin} \u2014 ${gateway}`,
429
600
  `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
430
601
  `- Token file: ${config.tokenFile} \u2014 ${tokenFileState(config.tokenFile)}`,
431
602
  `- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
432
603
  `- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
433
604
  `- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
605
+ `- Lifecycle notifications: ${config.lifecycle.enabled ? "enabled" : "disabled"}`,
606
+ ` - Completed sessions: ${config.lifecycle.completed ? "enabled" : "disabled"} (minimum ${config.lifecycle.completionMinSeconds}s)`,
607
+ ` - Session errors: ${config.lifecycle.errors ? "enabled" : "disabled"}`,
608
+ ` - Retry warnings: ${config.lifecycle.retries ? "enabled" : "disabled"} (attempt ${config.lifecycle.retryAttemptThreshold}+)`,
609
+ ` - OpenCode updates: ${config.lifecycle.updates ? "enabled" : "disabled"}`,
434
610
  `- Command stream: ${permissionStatus.stream}`,
435
611
  `- Pending permissions: ${permissionStatus.pending}`,
436
612
  `- Log level: ${config.logLevel}`,
@@ -443,6 +619,8 @@ function helpMessage() {
443
619
  "## Backeer commands",
444
620
  "",
445
621
  "- `/backeer` or `/backeer status` \u2014 show integration status",
622
+ "- `/backeer update-check` \u2014 check NPM for a newer version",
623
+ "- `/backeer update` \u2014 ask the OpenCode agent to install and verify it",
446
624
  "- `/backeer token` \u2014 show secure token-change instructions",
447
625
  "- `/backeer token-file /absolute/protected/path` \u2014 import and activate a token",
448
626
  "",
@@ -486,7 +664,8 @@ function createBackeerCommandHandler({
486
664
  client,
487
665
  config,
488
666
  permissionController,
489
- reporter
667
+ reporter,
668
+ updateChecker
490
669
  }) {
491
670
  return async (input, output) => {
492
671
  if (input.command !== "backeer") return;
@@ -498,7 +677,26 @@ function createBackeerCommandHandler({
498
677
  let message;
499
678
  try {
500
679
  if (!subcommand || subcommand === "status") {
501
- message = await buildStatus(config, permissionController);
680
+ message = await buildStatus(config, permissionController, updateChecker);
681
+ } else if (subcommand === "update-check") {
682
+ const update = await updateChecker.check({ force: true });
683
+ message = [
684
+ "## Backeer update check",
685
+ "",
686
+ update.state === "available" ? `Version ${update.latestVersion} is available; current version is ${update.currentVersion}. Use \`/backeer update\` to let the OpenCode agent install and verify it.` : update.state === "current" ? `Backeer is current at version ${update.currentVersion}.` : "The NPM registry could not be reached. No update was attempted."
687
+ ].join("\n");
688
+ } else if (subcommand === "update") {
689
+ const update = await updateChecker.check({ force: true });
690
+ if (update.state === "available") {
691
+ output.parts.push({
692
+ type: "text",
693
+ text: formatAgentUpdateInstruction(update)
694
+ });
695
+ return;
696
+ }
697
+ message = update.state === "current" ? `## Backeer update
698
+
699
+ Backeer is already current at version ${update.currentVersion}.` : "## Backeer update\n\nThe NPM registry could not be reached. No update was attempted.";
502
700
  } else if (subcommand === "token") {
503
701
  message = tokenInstructions();
504
702
  } else if (subcommand === "token-file") {
@@ -529,6 +727,342 @@ function createBackeerCommandHandler({
529
727
  };
530
728
  }
531
729
 
730
+ // src/lifecycle-publishers.js
731
+ var CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
732
+ var SECRET_ASSIGNMENT = /\b(token|secret|password|api[-_ ]?key)\s*[:=]\s*[^\s,;]+/gi;
733
+ var BEARER_TOKEN = /\bBearer\s+[^\s,;]+/gi;
734
+ var URL_WITH_QUERY = /\bhttps?:\/\/[^\s?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi;
735
+ var WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s]+\\)+[^\s]*/g;
736
+ var POSIX_PATH = /(^|\s)\/(?:[^/\s]+\/)+[^\s]*/g;
737
+ function sanitizeErrorMessage(value) {
738
+ return limit(value, 2e3).replace(CONTROL_CHARACTERS, " ").replace(BEARER_TOKEN, "Bearer [redacted]").replace(SECRET_ASSIGNMENT, "$1=[redacted]").replace(URL_WITH_QUERY, (value2) => {
739
+ try {
740
+ const url = new URL(value2);
741
+ return `${url.origin}${url.pathname}`;
742
+ } catch {
743
+ return "[url redacted]";
744
+ }
745
+ }).replace(WINDOWS_PATH, "[path redacted]").replace(POSIX_PATH, "$1[path redacted]").replace(/\s+/g, " ").trim().slice(0, 500);
746
+ }
747
+ function safeErrorDetails(error) {
748
+ const name = limit(error?.name || "OpenCodeError", 100);
749
+ const message = sanitizeErrorMessage(error?.data?.message ?? error?.message);
750
+ const statusCode = Number.isInteger(error?.data?.statusCode) ? String(error.data.statusCode) : "";
751
+ return { name, message, statusCode };
752
+ }
753
+ async function resolveSessionTitle2(client, directory, sessionID, reporter) {
754
+ if (!sessionID || typeof client?.session?.get !== "function") return "";
755
+ try {
756
+ const result = await client.session.get({
757
+ path: { id: sessionID },
758
+ query: { directory },
759
+ throwOnError: true
760
+ });
761
+ return limit((result?.data ?? result)?.title);
762
+ } catch (error) {
763
+ reporter.debug("Session title unavailable", error);
764
+ return "";
765
+ }
766
+ }
767
+ async function publish(config, reporter, event, feedback) {
768
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
769
+ type: "application",
770
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
771
+ notify: true,
772
+ direction: "in",
773
+ ...event
774
+ });
775
+ reporter.success(feedback, `HTTP ${result.status}`);
776
+ }
777
+ function secondsLabel(durationMs) {
778
+ const seconds = Math.max(0, Math.round(durationMs / 1e3));
779
+ if (seconds < 60) return `${seconds}s`;
780
+ const minutes = Math.floor(seconds / 60);
781
+ const remainder = seconds % 60;
782
+ return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
783
+ }
784
+ async function publishSessionCompleted({
785
+ config,
786
+ reporter,
787
+ client,
788
+ directory,
789
+ sessionID,
790
+ messageID,
791
+ durationMs
792
+ }) {
793
+ const sessionTitle = await resolveSessionTitle2(
794
+ client,
795
+ directory,
796
+ sessionID,
797
+ reporter
798
+ );
799
+ const duration = secondsLabel(durationMs);
800
+ const location = sessionTitle ? ` \u201C${sessionTitle}\u201D` : "";
801
+ await publish(config, reporter, {
802
+ severity: "info",
803
+ title: "OpenCode task completed",
804
+ body: `OpenCode finished${location} in ${duration}.`,
805
+ payload: [
806
+ { key: "OpenCode session", value: sessionTitle },
807
+ { key: "Outcome", value: "Completed" },
808
+ { key: "Duration", value: duration }
809
+ ].filter((item) => item.value),
810
+ externalId: `opencode:lifecycle:completed:${sessionID}:${messageID}`
811
+ }, "Task completion sent");
812
+ }
813
+ async function publishSessionRetry({
814
+ config,
815
+ reporter,
816
+ client,
817
+ directory,
818
+ sessionID,
819
+ correlation,
820
+ attempt,
821
+ next
822
+ }) {
823
+ const sessionTitle = await resolveSessionTitle2(
824
+ client,
825
+ directory,
826
+ sessionID,
827
+ reporter
828
+ );
829
+ const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : "";
830
+ await publish(config, reporter, {
831
+ severity: "warning",
832
+ title: "OpenCode is retrying",
833
+ body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
834
+ payload: [
835
+ { key: "OpenCode session", value: sessionTitle },
836
+ { key: "Retry attempt", value: String(attempt) },
837
+ { key: "Next retry", value: retryAt }
838
+ ].filter((item) => item.value),
839
+ externalId: `opencode:lifecycle:retry:${sessionID}:${correlation}`
840
+ }, "Retry warning sent");
841
+ }
842
+ async function publishSessionError({
843
+ config,
844
+ reporter,
845
+ client,
846
+ directory,
847
+ sessionID,
848
+ correlation,
849
+ error
850
+ }) {
851
+ const sessionTitle = await resolveSessionTitle2(
852
+ client,
853
+ directory,
854
+ sessionID,
855
+ reporter
856
+ );
857
+ const details = safeErrorDetails(error);
858
+ const location = sessionTitle ? ` in \u201C${sessionTitle}\u201D` : "";
859
+ const body = details.message ? `OpenCode failed${location}: ${details.message}` : `OpenCode failed${location}.`;
860
+ await publish(config, reporter, {
861
+ // Backeer/iOS currently support info, warning and critical severities.
862
+ severity: "critical",
863
+ title: "OpenCode task failed",
864
+ body: limit(body, 1e3),
865
+ payload: [
866
+ { key: "OpenCode session", value: sessionTitle },
867
+ { key: "Outcome", value: "Failed" },
868
+ { key: "Error type", value: details.name },
869
+ { key: "HTTP status", value: details.statusCode },
870
+ { key: "Error", value: details.message }
871
+ ].filter((item) => item.value),
872
+ externalId: `opencode:lifecycle:error:${sessionID || "global"}:${correlation}`
873
+ }, "Task failure sent");
874
+ }
875
+ async function publishOpenCodeUpdate({ config, reporter, version }) {
876
+ const safeVersion = limit(version, 100);
877
+ await publish(config, reporter, {
878
+ severity: "info",
879
+ title: "OpenCode update available",
880
+ body: `OpenCode ${safeVersion} is available.`,
881
+ payload: [{ key: "Available version", value: safeVersion }],
882
+ externalId: `opencode:lifecycle:update:${safeVersion}`
883
+ }, "OpenCode update sent");
884
+ }
885
+ function errorCorrelation(event, sessionState) {
886
+ if (typeof event?.id === "string" && event.id) return limit(event.id, 200);
887
+ if (sessionState?.lastAssistant?.id) return limit(sessionState.lastAssistant.id, 200);
888
+ const details = safeErrorDetails(event?.properties?.error);
889
+ return stableHash(`${details.name}:${details.statusCode}:${details.message}`);
890
+ }
891
+
892
+ // src/lifecycle-controller.js
893
+ var QUESTION_EVENTS = /* @__PURE__ */ new Set(["question.asked", "question.v2.asked"]);
894
+ var QUESTION_REPLY_EVENTS = /* @__PURE__ */ new Set([
895
+ "question.replied",
896
+ "question.v2.replied",
897
+ "question.answered",
898
+ "question.v2.answered"
899
+ ]);
900
+ function createLifecycleController({
901
+ config,
902
+ reporter,
903
+ client,
904
+ directory,
905
+ now = () => Date.now(),
906
+ publishers = {}
907
+ }) {
908
+ const sendCompleted = publishers.completed ?? publishSessionCompleted;
909
+ const sendError = publishers.error ?? publishSessionError;
910
+ const sendRetry = publishers.retry ?? publishSessionRetry;
911
+ const sendUpdate = publishers.update ?? publishOpenCodeUpdate;
912
+ const sessions = /* @__PURE__ */ new Map();
913
+ const publishedUpdates = /* @__PURE__ */ new Map();
914
+ function sessionState(sessionID) {
915
+ let state = sessions.get(sessionID);
916
+ if (!state) {
917
+ state = {
918
+ busy: false,
919
+ busySince: 0,
920
+ failed: false,
921
+ retryNotified: false,
922
+ waiting: false,
923
+ lastAssistant: void 0,
924
+ completedMessageID: ""
925
+ };
926
+ rememberBounded(sessions, sessionID, state);
927
+ }
928
+ return state;
929
+ }
930
+ function dispatch(label, operation) {
931
+ void Promise.resolve(operation).catch((error) => reporter.error(label, error));
932
+ }
933
+ function beginCycle(state) {
934
+ if (state.busy) return;
935
+ state.busy = true;
936
+ state.busySince = now();
937
+ state.failed = false;
938
+ state.retryNotified = false;
939
+ state.waiting = false;
940
+ state.lastAssistant = void 0;
941
+ }
942
+ function finishCycle(sessionID, state) {
943
+ if (!state.busy) return;
944
+ state.busy = false;
945
+ const assistant = state.lastAssistant;
946
+ const durationMs = Math.max(0, now() - state.busySince);
947
+ const qualifies = assistant?.id && assistant.completed && !assistant.error && !state.failed && !state.waiting && durationMs >= config.lifecycle.completionMinSeconds * 1e3 && state.completedMessageID !== assistant.id;
948
+ if (!qualifies || !config.lifecycle.completed) return;
949
+ state.completedMessageID = assistant.id;
950
+ dispatch(
951
+ "Task completion could not be sent",
952
+ sendCompleted({
953
+ config,
954
+ reporter,
955
+ client,
956
+ directory,
957
+ sessionID,
958
+ messageID: limit(assistant.id, 200),
959
+ durationMs
960
+ })
961
+ );
962
+ }
963
+ function handleMessage(event) {
964
+ const info = event.properties?.info;
965
+ if (info?.role !== "assistant" || typeof info.sessionID !== "string") return;
966
+ const state = sessionState(info.sessionID);
967
+ state.lastAssistant = {
968
+ id: typeof info.id === "string" ? info.id : "",
969
+ completed: Number.isFinite(info.time?.completed),
970
+ error: Boolean(info.error)
971
+ };
972
+ if (info.error) state.failed = true;
973
+ }
974
+ function handleStatus(event) {
975
+ const sessionID = event.properties?.sessionID;
976
+ const status = event.properties?.status;
977
+ if (typeof sessionID !== "string" || typeof status?.type !== "string") return;
978
+ const state = sessionState(sessionID);
979
+ if (status.type === "busy") {
980
+ beginCycle(state);
981
+ return;
982
+ }
983
+ if (status.type === "idle") {
984
+ finishCycle(sessionID, state);
985
+ return;
986
+ }
987
+ if (status.type !== "retry") return;
988
+ beginCycle(state);
989
+ if (!config.lifecycle.retries || state.retryNotified) return;
990
+ if (!Number.isInteger(status.attempt) || status.attempt < config.lifecycle.retryAttemptThreshold) return;
991
+ state.retryNotified = true;
992
+ const correlation = state.lastAssistant?.id || stableHash(
993
+ `${status.attempt}:${Number.isFinite(status.next) ? status.next : "unknown"}`
994
+ );
995
+ dispatch(
996
+ "Retry warning could not be sent",
997
+ sendRetry({
998
+ config,
999
+ reporter,
1000
+ client,
1001
+ directory,
1002
+ sessionID,
1003
+ correlation: limit(correlation, 200),
1004
+ attempt: status.attempt,
1005
+ next: status.next
1006
+ })
1007
+ );
1008
+ }
1009
+ function handleError(event) {
1010
+ const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : "";
1011
+ const state = sessionID ? sessionState(sessionID) : void 0;
1012
+ if (state) state.failed = true;
1013
+ if (!config.lifecycle.errors) return;
1014
+ dispatch(
1015
+ "Task failure could not be sent",
1016
+ sendError({
1017
+ config,
1018
+ reporter,
1019
+ client,
1020
+ directory,
1021
+ sessionID,
1022
+ correlation: errorCorrelation(event, state),
1023
+ error: event.properties?.error
1024
+ })
1025
+ );
1026
+ }
1027
+ function handleWaiting(event, waiting) {
1028
+ const sessionID = event.properties?.sessionID;
1029
+ if (typeof sessionID !== "string") return;
1030
+ sessionState(sessionID).waiting = waiting;
1031
+ }
1032
+ return {
1033
+ handle(event) {
1034
+ if (!config.lifecycle.enabled || !event?.type) return;
1035
+ if (event.type === "message.updated") {
1036
+ handleMessage(event);
1037
+ } else if (event.type === "session.status") {
1038
+ handleStatus(event);
1039
+ } else if (event.type === "session.idle") {
1040
+ const sessionID = event.properties?.sessionID;
1041
+ if (typeof sessionID === "string") finishCycle(sessionID, sessionState(sessionID));
1042
+ } else if (event.type === "session.error") {
1043
+ handleError(event);
1044
+ } else if (event.type === "permission.asked" || QUESTION_EVENTS.has(event.type)) {
1045
+ handleWaiting(event, true);
1046
+ } else if (event.type === "permission.replied" || QUESTION_REPLY_EVENTS.has(event.type)) {
1047
+ handleWaiting(event, false);
1048
+ } else if (event.type === "installation.update-available" && config.lifecycle.updates) {
1049
+ const version = event.properties?.version;
1050
+ if (typeof version !== "string" || !version.trim()) return;
1051
+ if (publishedUpdates.has(version)) return;
1052
+ rememberBounded(publishedUpdates, version, true);
1053
+ dispatch(
1054
+ "OpenCode update could not be sent",
1055
+ sendUpdate({ config, reporter, version: version.trim() })
1056
+ );
1057
+ }
1058
+ },
1059
+ dispose() {
1060
+ sessions.clear();
1061
+ publishedUpdates.clear();
1062
+ }
1063
+ };
1064
+ }
1065
+
532
1066
  // src/sse.js
533
1067
  function parseSseFrame(frame) {
534
1068
  const data = [];
@@ -833,14 +1367,23 @@ async function BackeerPlugin({ client, directory }, options = {}) {
833
1367
  client,
834
1368
  directory
835
1369
  });
1370
+ const lifecycleController = createLifecycleController({
1371
+ config,
1372
+ reporter,
1373
+ client,
1374
+ directory
1375
+ });
836
1376
  const todoCompletionBySessionID = /* @__PURE__ */ new Map();
837
1377
  const publishedQuestions = /* @__PURE__ */ new Map();
1378
+ const updateChecker = createUpdateChecker();
838
1379
  const handleBackeerCommand = createBackeerCommandHandler({
839
1380
  client,
840
1381
  config,
841
1382
  permissionController,
842
- reporter
1383
+ reporter,
1384
+ updateChecker
843
1385
  });
1386
+ const handleUpdateNotice = createUpdateNoticeHook(updateChecker);
844
1387
  if (!config.token) {
845
1388
  reporter.error(
846
1389
  "Notifications disabled \u2014 use /backeer token",
@@ -903,8 +1446,10 @@ async function BackeerPlugin({ client, directory }, options = {}) {
903
1446
  registerBackeerCommand(opencodeConfig);
904
1447
  },
905
1448
  "command.execute.before": handleBackeerCommand,
1449
+ "chat.message": handleUpdateNotice,
906
1450
  event: async ({ event }) => {
907
1451
  if (!config.token) return;
1452
+ lifecycleController.handle(event);
908
1453
  if (config.permissions && event.type === "permission.asked") {
909
1454
  await handlePermissionAsked(event);
910
1455
  return;
@@ -925,6 +1470,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
925
1470
  },
926
1471
  dispose: async () => {
927
1472
  await permissionController.dispose();
1473
+ lifecycleController.dispose();
928
1474
  todoCompletionBySessionID.clear();
929
1475
  publishedQuestions.clear();
930
1476
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.1.4",
4
- "description": "Backeer notifications and remote permission decisions for OpenCode",
3
+ "version": "0.2.0",
4
+ "description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {