kimaki 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/agent-model.e2e.test.js +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/commands/agent.js +87 -74
  8. package/dist/commands/model.js +18 -0
  9. package/dist/commands/multioauth.js +169 -1
  10. package/dist/commands/tasks.js +6 -0
  11. package/dist/commands/unset-model.js +5 -20
  12. package/dist/database.js +65 -0
  13. package/dist/discord-bot.js +7 -1
  14. package/dist/ipc-tools-plugin.js +4 -3
  15. package/dist/kimaki-opencode-plugin.js +2 -0
  16. package/dist/message-formatting.js +84 -5
  17. package/dist/message-formatting.test.js +160 -2
  18. package/dist/oauth-rotation-shared.js +7 -1
  19. package/dist/openai-auth-state.js +20 -0
  20. package/dist/schema.js +19 -0
  21. package/dist/session-handler/event-stream-state.js +147 -0
  22. package/dist/session-handler/event-stream-state.test.js +437 -1
  23. package/dist/session-handler/thread-session-runtime.js +90 -37
  24. package/dist/subagent-rate-limit-plugin.js +13 -2
  25. package/dist/system-message.js +18 -4
  26. package/dist/system-message.test.js +28 -4
  27. package/dist/task-runner.js +143 -12
  28. package/dist/task-schedule.js +12 -0
  29. package/dist/task-schedule.test.js +149 -2
  30. package/dist/xai-auth-plugin.js +237 -0
  31. package/dist/xai-auth-state.js +233 -0
  32. package/dist/xai-auth-state.test.js +183 -0
  33. package/package.json +6 -6
  34. package/skills/profano/SKILL.md +5 -3
  35. package/skills/spiceflow/SKILL.md +24 -1
  36. package/src/agent-model.e2e.test.ts +172 -4
  37. package/src/analytics.test.ts +30 -1
  38. package/src/analytics.ts +1 -0
  39. package/src/cli-commands/send.ts +22 -3
  40. package/src/cli-commands/task.ts +32 -19
  41. package/src/cli-runner.test.ts +16 -1
  42. package/src/cli-runner.ts +88 -89
  43. package/src/commands/agent.ts +120 -84
  44. package/src/commands/model.ts +25 -0
  45. package/src/commands/multioauth.ts +195 -2
  46. package/src/commands/tasks.ts +6 -0
  47. package/src/commands/unset-model.ts +5 -21
  48. package/src/database.ts +124 -0
  49. package/src/discord-bot.ts +7 -1
  50. package/src/ipc-tools-plugin.ts +4 -3
  51. package/src/kimaki-opencode-plugin.ts +2 -0
  52. package/src/message-formatting.test.ts +197 -2
  53. package/src/message-formatting.ts +98 -5
  54. package/src/oauth-rotation-shared.ts +6 -1
  55. package/src/openai-auth-state.ts +19 -0
  56. package/src/schema.sql +15 -0
  57. package/src/schema.ts +20 -0
  58. package/src/session-handler/event-stream-state.test.ts +490 -0
  59. package/src/session-handler/event-stream-state.ts +210 -0
  60. package/src/session-handler/model-utils.ts +1 -0
  61. package/src/session-handler/thread-session-runtime.ts +105 -44
  62. package/src/subagent-rate-limit-plugin.ts +12 -2
  63. package/src/system-message.test.ts +29 -4
  64. package/src/system-message.ts +20 -4
  65. package/src/task-runner.ts +168 -11
  66. package/src/task-schedule.test.ts +172 -2
  67. package/src/task-schedule.ts +23 -2
  68. package/src/xai-auth-plugin.ts +302 -0
  69. package/src/xai-auth-state.test.ts +224 -0
  70. package/src/xai-auth-state.ts +293 -0
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { describe, expect, test } from 'vitest';
6
6
  import {} from './opencode-session-event-log.js';
7
- import { derivePendingPermissionRequests, getAssistantMessageIdsForLatestUserTurn, getDerivedSubagentSessions, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
7
+ import { derivePendingPermissionRequests, getAssistantMessageIdsForLatestUserTurn, getDerivedSubagentSessions, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, getLatestTurnTokenUsage, getIdleTokenUsageDelta, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
8
8
  const fixturesDir = path.join(import.meta.dirname, 'event-stream-fixtures');
9
9
  function loadFixture(filename) {
10
10
  const content = fs.readFileSync(path.join(fixturesDir, filename), 'utf8');
@@ -741,3 +741,439 @@ describe('real-session-footer-suppressed-on-pre-idle-interrupt', () => {
741
741
  })).toBe(true);
742
742
  });
743
743
  });
744
+ describe('getLatestTurnTokenUsage', () => {
745
+ function userEvent({ sessionId, messageId, created, }) {
746
+ return eventEntry({
747
+ type: 'message.updated',
748
+ properties: {
749
+ sessionID: sessionId,
750
+ info: {
751
+ id: messageId,
752
+ sessionID: sessionId,
753
+ role: 'user',
754
+ time: { created },
755
+ agent: 'build',
756
+ model: {
757
+ providerID: 'openai',
758
+ modelID: 'gpt-5.3-codex',
759
+ },
760
+ },
761
+ },
762
+ });
763
+ }
764
+ function assistantEvent({ sessionId, messageId, parentID, created, tokens, cost = 0, modelID = 'gpt-5.3-codex', providerID = 'openai', }) {
765
+ return eventEntry({
766
+ type: 'message.updated',
767
+ properties: {
768
+ sessionID: sessionId,
769
+ info: {
770
+ id: messageId,
771
+ sessionID: sessionId,
772
+ role: 'assistant',
773
+ time: { created, completed: created + 1 },
774
+ parentID,
775
+ modelID,
776
+ providerID,
777
+ mode: 'build',
778
+ agent: 'build',
779
+ path: { cwd: '/test', root: '/test' },
780
+ cost,
781
+ tokens,
782
+ finish: 'stop',
783
+ },
784
+ },
785
+ });
786
+ }
787
+ test('sums latest snapshot per assistant message in the latest turn', () => {
788
+ const sessionId = 'ses_tokens';
789
+ const events = [
790
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
791
+ assistantEvent({
792
+ sessionId,
793
+ messageId: 'msg_asst_1',
794
+ parentID: 'msg_user_1',
795
+ created: 2,
796
+ tokens: {
797
+ input: 0,
798
+ output: 0,
799
+ reasoning: 0,
800
+ cache: { read: 0, write: 0 },
801
+ },
802
+ }),
803
+ assistantEvent({
804
+ sessionId,
805
+ messageId: 'msg_asst_1',
806
+ parentID: 'msg_user_1',
807
+ created: 2,
808
+ tokens: {
809
+ input: 100,
810
+ output: 20,
811
+ reasoning: 5,
812
+ cache: { read: 10, write: 2 },
813
+ },
814
+ cost: 1,
815
+ }),
816
+ assistantEvent({
817
+ sessionId,
818
+ messageId: 'msg_asst_2',
819
+ parentID: 'msg_user_1',
820
+ created: 3,
821
+ tokens: {
822
+ input: 50,
823
+ output: 8,
824
+ reasoning: 1,
825
+ cache: { read: 4, write: 0 },
826
+ },
827
+ cost: 2,
828
+ }),
829
+ ];
830
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
831
+ input: 150,
832
+ output: 28,
833
+ reasoning: 6,
834
+ cacheRead: 14,
835
+ cacheWrite: 2,
836
+ total: 200,
837
+ cost: 3,
838
+ model: 'gpt-5.3-codex',
839
+ providerID: 'openai',
840
+ assistantMessageCount: 2,
841
+ userMessageId: 'msg_user_1',
842
+ });
843
+ });
844
+ test('ignores previous-turn assistant messages', () => {
845
+ const sessionId = 'ses_tokens_turns';
846
+ const events = [
847
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
848
+ assistantEvent({
849
+ sessionId,
850
+ messageId: 'msg_asst_1',
851
+ parentID: 'msg_user_1',
852
+ created: 2,
853
+ tokens: {
854
+ input: 1000,
855
+ output: 100,
856
+ reasoning: 0,
857
+ cache: { read: 0, write: 0 },
858
+ },
859
+ }),
860
+ userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
861
+ assistantEvent({
862
+ sessionId,
863
+ messageId: 'msg_asst_2',
864
+ parentID: 'msg_user_2',
865
+ created: 4,
866
+ tokens: {
867
+ input: 7,
868
+ output: 3,
869
+ reasoning: 0,
870
+ cache: { read: 0, write: 0 },
871
+ },
872
+ modelID: 'gemini-2.5-flash',
873
+ providerID: 'google',
874
+ }),
875
+ ];
876
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
877
+ input: 7,
878
+ output: 3,
879
+ reasoning: 0,
880
+ cacheRead: 0,
881
+ cacheWrite: 0,
882
+ total: 10,
883
+ cost: 0,
884
+ model: 'gemini-2.5-flash',
885
+ providerID: 'google',
886
+ assistantMessageCount: 1,
887
+ userMessageId: 'msg_user_2',
888
+ });
889
+ });
890
+ test('returns zeros when the latest turn has no assistant tokens', () => {
891
+ const sessionId = 'ses_empty';
892
+ const events = [
893
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
894
+ ];
895
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
896
+ input: 0,
897
+ output: 0,
898
+ reasoning: 0,
899
+ cacheRead: 0,
900
+ cacheWrite: 0,
901
+ total: 0,
902
+ cost: 0,
903
+ model: undefined,
904
+ providerID: undefined,
905
+ assistantMessageCount: 0,
906
+ userMessageId: 'msg_user_1',
907
+ });
908
+ });
909
+ test('real-session-task-normal uses the completed assistant snapshot', () => {
910
+ const events = loadFixture('real-session-task-normal.jsonl');
911
+ const sessionId = getSessionId(events);
912
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
913
+ input: 39025,
914
+ output: 0,
915
+ reasoning: 0,
916
+ cacheRead: 0,
917
+ cacheWrite: 0,
918
+ total: 39025,
919
+ cost: 0,
920
+ model: 'gemini-2.5-flash',
921
+ providerID: 'cached-google-real-events',
922
+ assistantMessageCount: 1,
923
+ userMessageId: 'msg_cb9aae4c9001CxCOkoqgiXRsi1',
924
+ });
925
+ });
926
+ test('real-session-task-user-interruption sums both assistant steps', () => {
927
+ const events = loadFixture('real-session-task-user-interruption.jsonl');
928
+ const sessionId = getSessionId(events);
929
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
930
+ input: 82526,
931
+ output: 79,
932
+ reasoning: 115,
933
+ cacheRead: 0,
934
+ cacheWrite: 0,
935
+ total: 82720,
936
+ cost: 0,
937
+ model: 'gemini-2.5-flash',
938
+ providerID: 'cached-google-real-events',
939
+ assistantMessageCount: 2,
940
+ userMessageId: 'msg_cb9b0ba6c001i3YH7bGffdB6BF',
941
+ });
942
+ });
943
+ test('uses tokens.total when it differs from the component sum', () => {
944
+ const sessionId = 'ses_canonical_total';
945
+ const events = [
946
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
947
+ assistantEvent({
948
+ sessionId,
949
+ messageId: 'msg_asst_1',
950
+ parentID: 'msg_user_1',
951
+ created: 2,
952
+ tokens: {
953
+ total: 47319,
954
+ input: 1217,
955
+ output: 278,
956
+ reasoning: 54,
957
+ cache: { read: 45824, write: 0 },
958
+ },
959
+ }),
960
+ ];
961
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toMatchObject({
962
+ input: 1217,
963
+ output: 278,
964
+ reasoning: 54,
965
+ cacheRead: 45824,
966
+ cacheWrite: 0,
967
+ total: 47319,
968
+ assistantMessageCount: 1,
969
+ });
970
+ });
971
+ test('real-session-task-three-parallel-sleeps uses billed totals', () => {
972
+ const events = loadFixture('real-session-task-three-parallel-sleeps.jsonl');
973
+ const sessionId = getSessionId(events);
974
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toMatchObject({
975
+ input: 47139,
976
+ output: 1093,
977
+ reasoning: 472,
978
+ cacheRead: 45824,
979
+ cacheWrite: 0,
980
+ total: 94056,
981
+ model: 'gpt-5.3-codex',
982
+ providerID: 'openai',
983
+ assistantMessageCount: 2,
984
+ });
985
+ });
986
+ test('returns empty when the session has no user message', () => {
987
+ const sessionId = 'ses_no_user';
988
+ const events = [
989
+ assistantEvent({
990
+ sessionId,
991
+ messageId: 'msg_asst_1',
992
+ parentID: 'msg_missing',
993
+ created: 2,
994
+ tokens: {
995
+ input: 100,
996
+ output: 10,
997
+ reasoning: 0,
998
+ cache: { read: 0, write: 0 },
999
+ },
1000
+ }),
1001
+ ];
1002
+ expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
1003
+ input: 0,
1004
+ output: 0,
1005
+ reasoning: 0,
1006
+ cacheRead: 0,
1007
+ cacheWrite: 0,
1008
+ total: 0,
1009
+ cost: 0,
1010
+ model: undefined,
1011
+ providerID: undefined,
1012
+ assistantMessageCount: 0,
1013
+ userMessageId: undefined,
1014
+ });
1015
+ });
1016
+ });
1017
+ describe('getIdleTokenUsageDelta', () => {
1018
+ function idleEvent(sessionId) {
1019
+ return eventEntry({
1020
+ type: 'session.idle',
1021
+ properties: { sessionID: sessionId },
1022
+ });
1023
+ }
1024
+ test('emits the first idle snapshot and skips a later idle with the same tokens', () => {
1025
+ const sessionId = 'ses_delta';
1026
+ const events = [
1027
+ eventEntry({
1028
+ type: 'message.updated',
1029
+ properties: {
1030
+ sessionID: sessionId,
1031
+ info: {
1032
+ id: 'msg_user_1',
1033
+ sessionID: sessionId,
1034
+ role: 'user',
1035
+ time: { created: 1 },
1036
+ agent: 'build',
1037
+ model: {
1038
+ providerID: 'openai',
1039
+ modelID: 'gpt-5.3-codex',
1040
+ },
1041
+ },
1042
+ },
1043
+ }),
1044
+ eventEntry({
1045
+ type: 'message.updated',
1046
+ properties: {
1047
+ sessionID: sessionId,
1048
+ info: {
1049
+ id: 'msg_asst_1',
1050
+ sessionID: sessionId,
1051
+ role: 'assistant',
1052
+ time: { created: 2, completed: 3 },
1053
+ parentID: 'msg_user_1',
1054
+ modelID: 'gpt-5.3-codex',
1055
+ providerID: 'openai',
1056
+ mode: 'build',
1057
+ agent: 'build',
1058
+ path: { cwd: '/test', root: '/test' },
1059
+ cost: 0,
1060
+ tokens: {
1061
+ input: 10,
1062
+ output: 5,
1063
+ reasoning: 0,
1064
+ cache: { read: 0, write: 0 },
1065
+ },
1066
+ finish: 'stop',
1067
+ },
1068
+ },
1069
+ }),
1070
+ idleEvent(sessionId),
1071
+ idleEvent(sessionId),
1072
+ ];
1073
+ const firstIdleIndex = events.length - 2;
1074
+ const secondIdleIndex = events.length - 1;
1075
+ expect(getIdleTokenUsageDelta({
1076
+ events,
1077
+ sessionId,
1078
+ idleEventIndex: firstIdleIndex,
1079
+ })).toMatchObject({
1080
+ total: 15,
1081
+ });
1082
+ expect(getIdleTokenUsageDelta({
1083
+ events,
1084
+ sessionId,
1085
+ idleEventIndex: secondIdleIndex,
1086
+ })).toBeUndefined();
1087
+ });
1088
+ test('emits the growth after an early idle that saw zero tokens', () => {
1089
+ const sessionId = 'ses_late_tokens';
1090
+ const events = [
1091
+ eventEntry({
1092
+ type: 'message.updated',
1093
+ properties: {
1094
+ sessionID: sessionId,
1095
+ info: {
1096
+ id: 'msg_user_1',
1097
+ sessionID: sessionId,
1098
+ role: 'user',
1099
+ time: { created: 1 },
1100
+ agent: 'build',
1101
+ model: {
1102
+ providerID: 'openai',
1103
+ modelID: 'gpt-5.3-codex',
1104
+ },
1105
+ },
1106
+ },
1107
+ }),
1108
+ eventEntry({
1109
+ type: 'message.updated',
1110
+ properties: {
1111
+ sessionID: sessionId,
1112
+ info: {
1113
+ id: 'msg_asst_1',
1114
+ sessionID: sessionId,
1115
+ role: 'assistant',
1116
+ time: { created: 2 },
1117
+ parentID: 'msg_user_1',
1118
+ modelID: 'gpt-5.3-codex',
1119
+ providerID: 'openai',
1120
+ mode: 'build',
1121
+ agent: 'build',
1122
+ path: { cwd: '/test', root: '/test' },
1123
+ cost: 0,
1124
+ tokens: {
1125
+ input: 0,
1126
+ output: 0,
1127
+ reasoning: 0,
1128
+ cache: { read: 0, write: 0 },
1129
+ },
1130
+ },
1131
+ },
1132
+ }),
1133
+ idleEvent(sessionId),
1134
+ eventEntry({
1135
+ type: 'message.updated',
1136
+ properties: {
1137
+ sessionID: sessionId,
1138
+ info: {
1139
+ id: 'msg_asst_1',
1140
+ sessionID: sessionId,
1141
+ role: 'assistant',
1142
+ time: { created: 2, completed: 4 },
1143
+ parentID: 'msg_user_1',
1144
+ modelID: 'gpt-5.3-codex',
1145
+ providerID: 'openai',
1146
+ mode: 'build',
1147
+ agent: 'build',
1148
+ path: { cwd: '/test', root: '/test' },
1149
+ cost: 0,
1150
+ tokens: {
1151
+ total: 20,
1152
+ input: 12,
1153
+ output: 8,
1154
+ reasoning: 3,
1155
+ cache: { read: 0, write: 0 },
1156
+ },
1157
+ finish: 'stop',
1158
+ },
1159
+ },
1160
+ }),
1161
+ idleEvent(sessionId),
1162
+ ];
1163
+ expect(getIdleTokenUsageDelta({
1164
+ events,
1165
+ sessionId,
1166
+ idleEventIndex: 2,
1167
+ })).toBeUndefined();
1168
+ expect(getIdleTokenUsageDelta({
1169
+ events,
1170
+ sessionId,
1171
+ idleEventIndex: events.length - 1,
1172
+ })).toMatchObject({
1173
+ input: 12,
1174
+ output: 8,
1175
+ reasoning: 3,
1176
+ total: 20,
1177
+ });
1178
+ });
1179
+ });
@@ -16,8 +16,8 @@ import { isAbortError } from '../utils.js';
16
16
  import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
17
17
  import { createLogger, LogPrefix } from '../logger.js';
18
18
  import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
19
- import { formatPart } from '../message-formatting.js';
20
- import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
19
+ import { formatPart, formatTaskToolTitle } from '../message-formatting.js';
20
+ import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
21
21
  import * as orm from 'drizzle-orm';
22
22
  import * as schema from '../schema.js';
23
23
  import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
@@ -30,7 +30,7 @@ import { getDataDir } from '../config.js';
30
30
  import { trackEvent, } from '../analytics.js';
31
31
  import { resolveValidatedAgentPreference } from './agent-utils.js';
32
32
  import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
33
- import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
33
+ import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
34
34
  // Track multiple pending permissions per thread (keyed by permission ID).
35
35
  // OpenCode handles blocking/sequencing — we just need to track all pending
36
36
  // permissions to avoid duplicates and properly clean up on reply/teardown.
@@ -949,9 +949,20 @@ export class ThreadSessionRuntime {
949
949
  await this.handlePartUpdated(event.properties.part);
950
950
  break;
951
951
  case 'session.idle':
952
+ await completeScheduledTaskRunsForSession(event.properties.sessionID);
952
953
  await this.handleSessionIdle(event.properties.sessionID);
953
954
  break;
954
955
  case 'session.error':
956
+ if (event.properties.sessionID) {
957
+ const sessionError = event.properties.error;
958
+ const errorMessage = sessionError && typeof sessionError === 'object'
959
+ ? String(sessionError.data?.message || sessionError.name || 'Session failed')
960
+ : 'Session failed';
961
+ await failScheduledTaskRunsForSession({
962
+ sessionId: event.properties.sessionID,
963
+ error: errorMessage,
964
+ });
965
+ }
955
966
  await this.handleSessionError(event.properties);
956
967
  break;
957
968
  case 'permission.asked':
@@ -1433,6 +1444,35 @@ export class ThreadSessionRuntime {
1433
1444
  this.ensureTypingNow();
1434
1445
  return;
1435
1446
  }
1447
+ if (part.type === 'tool' &&
1448
+ part.tool === 'task' &&
1449
+ !this.state?.sentPartIds.has(part.id)) {
1450
+ const taskDisplay = formatTaskToolTitle(part);
1451
+ if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
1452
+ await this.flushBufferedParts({
1453
+ messageID: part.messageID,
1454
+ force: true,
1455
+ skipPartId: part.id,
1456
+ });
1457
+ threadState.updateThread(this.threadId, (t) => {
1458
+ const newIds = new Set(t.sentPartIds);
1459
+ newIds.add(part.id);
1460
+ return { ...t, sentPartIds: newIds };
1461
+ });
1462
+ const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
1463
+ .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1464
+ if (sendResult instanceof Error) {
1465
+ threadState.updateThread(this.threadId, (t) => {
1466
+ const newIds = new Set(t.sentPartIds);
1467
+ newIds.delete(part.id);
1468
+ return { ...t, sentPartIds: newIds };
1469
+ });
1470
+ discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1471
+ return;
1472
+ }
1473
+ await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1474
+ }
1475
+ }
1436
1476
  if (part.type === 'tool' && part.state.status === 'running') {
1437
1477
  await this.flushBufferedParts({
1438
1478
  messageID: part.messageID,
@@ -1440,40 +1480,6 @@ export class ThreadSessionRuntime {
1440
1480
  skipPartId: part.id,
1441
1481
  });
1442
1482
  await this.sendPartMessage({ part });
1443
- // Track task tool spawning subtask sessions
1444
- if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
1445
- const description = typeof part.state.input?.description === 'string'
1446
- ? part.state.input.description
1447
- : '';
1448
- const agent = typeof part.state.input?.subagent_type === 'string'
1449
- ? part.state.input.subagent_type
1450
- : 'task';
1451
- const childSessionId = typeof part.state.metadata?.sessionId === 'string'
1452
- ? part.state.metadata.sessionId
1453
- : '';
1454
- if (description && childSessionId) {
1455
- if ((await this.getVerbosity()) !== 'text_only') {
1456
- const taskDisplay = `┣ ${agent} **${description}**`;
1457
- threadState.updateThread(this.threadId, (t) => {
1458
- const newIds = new Set(t.sentPartIds);
1459
- newIds.add(part.id);
1460
- return { ...t, sentPartIds: newIds };
1461
- });
1462
- const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
1463
- .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1464
- if (sendResult instanceof Error) {
1465
- threadState.updateThread(this.threadId, (t) => {
1466
- const newIds = new Set(t.sentPartIds);
1467
- newIds.delete(part.id);
1468
- return { ...t, sentPartIds: newIds };
1469
- });
1470
- discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1471
- return;
1472
- }
1473
- await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1474
- }
1475
- }
1476
- }
1477
1483
  return;
1478
1484
  }
1479
1485
  // Action buttons tool handler
@@ -1637,7 +1643,47 @@ export class ThreadSessionRuntime {
1637
1643
  await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
1638
1644
  this.requestTypingRepulse();
1639
1645
  }
1646
+ trackIdleTokenUsage(idleSessionId) {
1647
+ let idleEventIndex;
1648
+ for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
1649
+ const event = this.eventBuffer[i]?.event;
1650
+ if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
1651
+ idleEventIndex = i;
1652
+ break;
1653
+ }
1654
+ }
1655
+ if (idleEventIndex === undefined) {
1656
+ return;
1657
+ }
1658
+ const usage = getIdleTokenUsageDelta({
1659
+ events: this.eventBuffer,
1660
+ sessionId: idleSessionId,
1661
+ idleEventIndex,
1662
+ });
1663
+ if (!usage) {
1664
+ return;
1665
+ }
1666
+ const properties = {
1667
+ tokens_input: usage.input,
1668
+ tokens_output: usage.output,
1669
+ tokens_reasoning: usage.reasoning,
1670
+ tokens_cache_read: usage.cacheRead,
1671
+ tokens_cache_write: usage.cacheWrite,
1672
+ tokens_total: usage.total,
1673
+ cost: usage.cost,
1674
+ assistant_message_count: usage.assistantMessageCount,
1675
+ is_subagent: Boolean(this.getSubtaskInfoForSession(idleSessionId)),
1676
+ };
1677
+ if (usage.model) {
1678
+ properties.model = usage.model;
1679
+ }
1680
+ if (usage.providerID) {
1681
+ properties.provider = usage.providerID;
1682
+ }
1683
+ trackEvent('tokens_used', properties);
1684
+ }
1640
1685
  async handleSessionIdle(idleSessionId) {
1686
+ this.trackIdleTokenUsage(idleSessionId);
1641
1687
  const sessionId = this.state?.sessionId;
1642
1688
  // ── Subtask idle ──────────────────────────────────────────
1643
1689
  const subtask = this.getSubtaskInfoForSession(idleSessionId);
@@ -2331,6 +2377,13 @@ export class ThreadSessionRuntime {
2331
2377
  await cleanupOnError(`✗ OpenCode API error: ${errorMessage}`);
2332
2378
  return;
2333
2379
  }
2380
+ if (input.sessionStartSource?.scheduledTaskRunId) {
2381
+ await startScheduledTaskRunSession({
2382
+ runId: input.sessionStartSource.scheduledTaskRunId,
2383
+ sessionId: session.id,
2384
+ projectDirectory: this.sdkDirectory,
2385
+ });
2386
+ }
2334
2387
  logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
2335
2388
  if (!input.noReply) {
2336
2389
  trackTurnStarted({
@@ -10,6 +10,9 @@ const RATE_LIMIT_TEXT_PATTERNS = [
10
10
  'retry after',
11
11
  'too many requests',
12
12
  'quota exceeded',
13
+ 'balance exhausted',
14
+ 'spending-limit',
15
+ 'run out of credits',
13
16
  ];
14
17
  function isRateLimitText(text) {
15
18
  if (!text) {
@@ -66,7 +69,7 @@ function extractRateLimitReason(event) {
66
69
  }
67
70
  if (event.type === 'message.part.updated' && event.properties.part.type === 'retry') {
68
71
  const retryError = event.properties.part.error;
69
- if (retryError.data.statusCode === 429) {
72
+ if (retryError.data.statusCode === 429 || retryError.data.statusCode === 402) {
70
73
  return retryError.data.message;
71
74
  }
72
75
  if (isRateLimitText(retryError.data.responseBody)) {
@@ -90,9 +93,17 @@ function extractRateLimitReason(event) {
90
93
  if (!apiError) {
91
94
  return undefined;
92
95
  }
93
- if (apiError.statusCode === 429) {
96
+ if (apiError.statusCode === 429 || apiError.statusCode === 402) {
94
97
  return apiError.message;
95
98
  }
99
+ // xAI returns 403 with spending-limit or run out of credits text.
100
+ // Only treat 403 as a usage limit if the body matches specific patterns,
101
+ // not all 403s (which can be auth errors handled elsewhere).
102
+ if (apiError.statusCode === 403) {
103
+ const errorText = [apiError.message, apiError.responseBody].filter(Boolean).join(' ');
104
+ if (isRateLimitText(errorText))
105
+ return apiError.message;
106
+ }
96
107
  if (isRateLimitText(apiError.responseBody)) {
97
108
  return apiError.responseBody;
98
109
  }